fix(issue-5): address phase 2a round 2 findings (F-C, F-D, R1, R4)

Both reviewers sustained the round 1 fixes and found four more. Each was
reproduced here before being accepted; none were rebutted.

F-C (claude, blocking) -- my own round 1 fix caused this. Making refused writes
exit non-zero also classified an idempotent ACK as a refusal, and the wrapper
finalises an approved job with `status --set completed` on a record that is
already completed. It runs under `set -e`, so the *success* path aborted with
exit 4 before its validation hook and its final stdout line. Reproduced at the
shell. An idempotent result is now distinguishable from a refusal and exits 0; a
genuine conflict still exits 4.

F-D (claude) -- routing the operator write around update_status dropped the
status vocabulary check. Under the rollback flag an invalid status was written
to the record with exit 0, where before it was refused with exit 1 and the record
left alone. The check now runs first, in both modes.

R1 (codex, blocking) -- the previous fix compared identifiers only when they
happened to be present, so evidence carrying none at all was still approved on
the dispatch record's own flags. That is the self-assertion the check exists to
replace. Every identifier must now be supplied and agree, and a report manifest
must associate with the attempt being judged. Identifiers are read from a
collector envelope beside the payload rather than from inside it: a native Herdr
payload may not carry MAM fields, so one appearing there is either fabricated or
a contract violation, and either way it is not evidence.

R4 (codex) -- a sequence number orders nothing unless the compare and the write
are atomic. Two flushes could both read the old value, the newer land first, and
the slower overwrite it. Reproduced deterministically. The mirror now serialises
read-compare-write under a per-job audit lock, taken outside the registry
transaction so a slow mirror still cannot lengthen it, and each write uses its
own temp file instead of a shared name.

All eight new regressions fail against the pre-fix code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-06 20:10:41 +09:00
1 parent 3b134e3358
commit cb575fa851
6 files changed
+298 -48

No files matched your search

+15 -6
View File
@@ -81,10 +81,16 @@ def cas_enabled() -> bool:
class TransitionResult(object):
__slots__ = ("committed", "from_status", "to_status", "reason", "record", "notifications")
__slots__ = ("committed", "from_status", "to_status", "reason", "record",
"notifications", "idempotent")
def __init__(self, committed, from_status, to_status, reason, record, notifications):
def __init__(self, committed, from_status, to_status, reason, record, notifications,
idempotent=False):
self.committed = committed
# Not every uncommitted transition is a refusal. Re-receiving a terminal
# that already holds is a no-op ACK in the contract table, and a caller
# that treats it as failure turns a success path into an error.
self.idempotent = idempotent
self.from_status = from_status
self.to_status = to_status
self.reason = reason
@@ -97,13 +103,14 @@ class TransitionResult(object):
class _Decision(object):
__slots__ = ("commit", "to_status", "reason", "conflict")
__slots__ = ("commit", "to_status", "reason", "conflict", "idempotent")
def __init__(self, commit, to_status, reason, conflict=False):
def __init__(self, commit, to_status, reason, conflict=False, idempotent=False):
self.commit = commit
self.to_status = to_status
self.reason = reason
self.conflict = conflict
self.idempotent = idempotent
def _decide(record: Dict[str, Any], request: str, expected_status, expected_attempt,
@@ -156,7 +163,8 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
if current in TERMINAL_STATUSES:
# Idempotent re-receipt of the same terminal is a no-op ack.
if requested == current:
return _Decision(False, current, "idempotent: already {}".format(current))
return _Decision(False, current, "idempotent: already {}".format(current),
idempotent=True)
operator = source in OPERATOR_SOURCES
if operator and request == "redispatch":
@@ -249,7 +257,8 @@ def transition(job_id, request="merge", *, registry_dir=None, expected_status=No
logger.warning("refused %s for job %s: %s", request, job_id, decision.reason)
return TransitionResult(decision.commit, from_status, to_status,
decision.reason, record, notifications)
decision.reason, record, notifications,
idempotent=decision.idempotent)
def _flush(notifications, registry_dir) -> None:
+37 -18
View File
@@ -577,25 +577,44 @@ def update_logged_status(job_id: str, status: str, logs_dir: Optional[str] = Non
path = job_log_path(job_id, STATUS_FILENAME, logs_dir)
path.parent.mkdir(parents=True, exist_ok=True)
incoming_seq = extras.get("transition_seq")
if incoming_seq is not None and path.exists():
try:
existing = json.loads(path.read_text(encoding="utf-8"))
existing_seq = existing.get("transition_seq")
if existing_seq is not None and int(existing_seq) >= int(incoming_seq):
logger.debug("skipping stale status mirror for %s (seq %s <= %s)",
job_id, incoming_seq, existing_seq)
return
except Exception:
pass # unreadable existing file: fall through and rewrite it
# Compare and write under one per-job lock. A sequence number only orders
# writers that cannot interleave: with a bare check-then-write, two
# flushes can both read the old value, the newer one lands first, and the
# slower one then overwrites it. The lock is deliberately its own file so
# it is never confused with the registry lock, and it is taken here --
# outside the registry transaction -- so a slow mirror still cannot
# lengthen that transaction.
lock_path = path.with_name("." + path.name + ".lock")
with open(lock_path, "a+") as lock_fh:
with _file_lock(lock_fh):
incoming_seq = extras.get("transition_seq")
if incoming_seq is not None and path.exists():
try:
existing = json.loads(path.read_text(encoding="utf-8"))
existing_seq = existing.get("transition_seq")
if existing_seq is not None and int(existing_seq) >= int(incoming_seq):
logger.debug("skipping stale status mirror for %s (seq %s <= %s)",
job_id, incoming_seq, existing_seq)
return
except Exception:
pass # unreadable existing file: fall through and rewrite it
record: Dict[str, Any] = {"job_id": job_id, "status": status, "updated_at": _utcnow()}
record.update(extras)
tmp = path.with_name(path.name + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(record, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, path)
record: Dict[str, Any] = {"job_id": job_id, "status": status,
"updated_at": _utcnow()}
record.update(extras)
# A per-write temp name: a shared one lets concurrent writers
# clobber each other's half-written file before the rename.
fd, tmp = tempfile.mkstemp(dir=str(path.parent),
prefix="." + path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(record, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
except Exception as exc: # pragma: no cover - best effort
logger.warning("update_logged_status failed for job %s: %s", job_id, exc)
+26 -6
View File
@@ -327,23 +327,43 @@ def _build_parser() -> argparse.ArgumentParser:
def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
"""Apply an operator-authority write and report refusal on the exit status.
"""Apply an operator-authority write and report a refusal on the exit status.
Before phase 2a this path could not be refused, so it always exited 0. Once
the CAS boundary can decline a write, exiting 0 anyway would hide it -- which
is exactly how a refused loop re-dispatch looked like success to the wrapper.
is how a refused loop re-dispatch looked like success to the wrapper.
Two things this must NOT do, both learned the hard way:
* treat an idempotent ACK as a refusal. The wrapper finalises an approved
job with `status --set completed` on a record that is already completed,
and it runs under `set -e`, so reporting failure there aborts the success
path before the validation hook and the final stdout line.
* skip the status vocabulary check. That validation used to live in
`update_status`; routing around it let an invalid status through, and in
the rollback mode it was actually written to the record.
"""
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
status = fields.get("status")
if status is not None and status not in VALID_STATUSES:
print("invalid status {!r}; expected one of {}".format(status, VALID_STATUSES),
file=sys.stderr)
return 1
if not engine.cas_enabled():
mqtt_common.update_job_status(job_id, registry_dir, **fields)
return 0
result = mqtt_common.transition_job_status(
job_id, registry_dir, _source="registry_cli", **fields)
if not result.committed:
print("refused: {}".format(result.reason), file=sys.stderr)
return 4
return 0
if result.committed:
return 0
if result.idempotent:
# The requested state already holds. Nothing to do, and nothing wrong.
return 0
print("refused: {}".format(result.reason), file=sys.stderr)
return 4
def main(argv: Optional[List[str]] = None) -> int:
+40 -16
View File
@@ -17,6 +17,11 @@ from lib_py.loop.evidence import Decision
# nothing about completion; it only makes completion *possible* to consider.
QUIESCENT = ("idle", "done")
# The four identifiers an external collector must supply before a completion can
# be attributed to this attempt.
ATTRIBUTION_FIELDS = ("job_id", "attempt_id", "agent_session_uuid",
"pane_process_generation")
def _timeline_by_kind(scenario):
out = {}
@@ -28,22 +33,32 @@ def _timeline_by_kind(scenario):
def collect_observed_identity(scenario) -> dict:
"""Gather identifiers the *evidence* carries, for comparison with dispatch.
Without this, attribution only proves the dispatch record is internally
well-formed -- it never confronts what was actually seen. Association of a
job or attempt to an observation is the external collector's business, so
these are read from the timeline items and the report manifest, never
invented as native Herdr fields.
Two sources, and the distinction matters. Association of a job or attempt to
an observation is the external collector's business, so it lives in a
``collector_attribution`` envelope beside the payload -- never inside it,
because a native Herdr payload must stay native (phase 0 forbids MAM fields
there, and a fabricated one would be indistinguishable from a real reading).
The report manifest is a filesystem fact and stays in that payload.
Every occurrence is kept. Taking only the first would drop a later,
contradicting observation, which is precisely what this exists to catch.
"""
observed = {}
for item in scenario.get("timeline") or []:
envelope = item.get("collector_attribution") or {}
for field in ATTRIBUTION_FIELDS:
if envelope.get(field) is not None:
observed.setdefault(field, []).append(envelope[field])
payload = item.get("payload") or {}
for field in ("job_id", "attempt_id", "agent_session_uuid",
"pane_process_generation"):
if payload.get(field) is not None:
# Every occurrence is kept. Taking only the first would let a
# later, contradicting observation pass unnoticed -- which is the
# opposite of what this collection is for.
observed.setdefault(field, []).append(payload[field])
if item.get("source_kind") != "herdr":
# A MAM-side source (an event, a filesystem fact) legitimately names
# the job or attempt in its own payload. A native Herdr payload never
# may: phase 0 forbids MAM fields there, so an identifier appearing
# inside one is either fabricated or a contract violation, and either
# way it is not evidence.
for field in ATTRIBUTION_FIELDS:
if payload.get(field) is not None:
observed.setdefault(field, []).append(payload[field])
manifest = payload.get("report_manifest")
if isinstance(manifest, dict) and manifest.get("attempt_id") is not None:
observed.setdefault("manifest_attempt_id", []).append(manifest["attempt_id"])
@@ -97,15 +112,24 @@ def decide(scenario) -> Decision:
if not attributed.ok:
return Decision("hold", attributed.reason)
for field, values in observed.items():
for value in values:
# Absence of proof is not proof. Comparing only the identifiers that happen
# to be present would let evidence carrying none at all be approved on the
# dispatch record's own say-so, which is the self-assertion this check exists
# to replace. Every identifier must be supplied, then must agree.
for field in ATTRIBUTION_FIELDS:
if not observed.get(field):
return Decision("hold",
"no observed {} to attribute this completion to".format(field))
for value in observed[field]:
if value != dispatch.get(field):
return Decision("hold", "attribution mismatch on {}: observed {!r}, "
"dispatched {!r}".format(field, value,
dispatch.get(field)))
# The finalized report must belong to the attempt being judged; a manifest
# from another attempt is evidence of a different run finishing.
# The finalized report must belong to the attempt being judged. A missing
# manifest attempt holds for the same reason: it proves nothing.
if not manifest_attempts:
return Decision("hold", "no report manifest attempt to associate with this attempt")
for manifest_attempt in manifest_attempts:
if manifest_attempt != dispatch.get("attempt_id"):
return Decision("hold",