fix(issue-5): address phase 2a cross-review findings (F-A, F-B, R1, R2, R3)
Two reviewers raised five defects. Every one was reproduced here before being accepted; none were rebutted. F-A (claude, blocking) -- the CAS table had no row for two transitions the wrapper legitimately makes, so `submit --type loop|discuss` silently stopped working by default: the per-iteration re-dispatch was refused and its metadata discarded, the exhausted-budget finalisation was refused, and the CLI returned 0 both times. Reproduced against the real modules. Fixed by naming those transitions rather than carving an exception: `redispatch` and `finalize` are requests gated on operator authority, so the wrapper can move a finished record while an agent still cannot touch its own terminal. A re-dispatch must advance the iteration, so a replay cannot resurrect a job. The operator CLI now exits non-zero with a reason when a write is refused -- the silence is what let this hide. F-B (claude) -- moving the audit mirror out of the lock made status.json last-writer-wins, so a slow flush could leave it behind the registry. It now carries transition_seq and skips a write that would go backwards, restoring the sequential consistency the lock used to provide for free. R1 (codex, blocking) -- the policy called attribution with only the dispatch record, so it proved the record was well-formed and never confronted the identifiers the evidence carried. A finalized scenario with a different session, a different attempt and a manifest from another attempt was still approved. Observed identifiers are now collected from the timeline and every one must agree; a manifest belonging to another attempt holds. Collecting every occurrence rather than the first matters, because taking the first would drop exactly the contradicting value this is meant to catch. R2 (codex, blocking) -- a supplied expected_attempt was ignored when the record had no attempt field, so a compare-and-swap with nothing to compare committed. It now fails closed, which matters because 2b plans to gate inferred completion on precisely that precondition. R3 (codex) -- a status could ride in as an ordinary field, so `progress` could commit a terminal from inside the boundary that exists to prevent it. Status is now settable only by the request that owns it, unknown requests are refused, and pending has an owning request instead of being an untyped merge. Each of the eleven new regressions was checked against the pre-fix code first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
17a2dcd988
commit
56d08efa95
6 files changed
+378
-23
No files matched your search
@@ -392,7 +392,8 @@ def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any])
|
||||
raise
|
||||
|
||||
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
_source: str = "unknown", **fields: Any) -> Dict[str, Any]:
|
||||
"""Merge ``fields`` into a job record through the single transition boundary.
|
||||
|
||||
Signature, return value and exceptions are unchanged: phase 1's G-1..G-4
|
||||
@@ -408,17 +409,31 @@ def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **f
|
||||
if not engine.cas_enabled():
|
||||
return _update_job_status_legacy(job_id, registry_dir, **fields)
|
||||
|
||||
# The status-to-request mapping lives in transition_job_status so it happens
|
||||
# exactly once; doing it here as well would strip the status before the
|
||||
# mapping could see it.
|
||||
result = transition_job_status(job_id, registry_dir, _source=_source, **fields)
|
||||
return result.record
|
||||
|
||||
|
||||
def transition_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
_source: str = "unknown", **fields: Any):
|
||||
"""Same write, but returns the full TransitionResult.
|
||||
|
||||
``update_job_status`` must keep returning the record because phase 1's
|
||||
contracts and the shell helpers depend on that shape. A caller that needs to
|
||||
know whether the transition was actually committed -- the operator CLI, which
|
||||
used to exit 0 on a silently refused write -- uses this instead.
|
||||
"""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
request = "merge"
|
||||
if "status" in fields:
|
||||
# A merged status value is really a transition request; routing it to the
|
||||
# owning request is what subjects it to the CAS table instead of letting
|
||||
# a raw field write slip past it.
|
||||
request = engine.STATUS_TO_REQUEST.get(fields["status"], "merge")
|
||||
if request != "merge":
|
||||
fields = {k: v for k, v in fields.items() if k != "status"}
|
||||
result = engine.transition(job_id, request, registry_dir=registry_dir,
|
||||
fields=fields, source="update_job_status")
|
||||
return result.record
|
||||
return engine.transition(job_id, request, registry_dir=registry_dir,
|
||||
fields=fields, source=_source)
|
||||
|
||||
|
||||
def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
@@ -549,10 +564,31 @@ def append_event(job_id: str, event_dict: Dict[str, Any], logs_dir: Optional[str
|
||||
|
||||
def update_logged_status(job_id: str, status: str, logs_dir: Optional[str] = None, **extras: Any) -> None:
|
||||
"""Rewrite ``<logs>/<job_id>/status.json`` (current status for fast point
|
||||
queries) atomically. Best-effort; merges any ``extras``."""
|
||||
queries) atomically. Best-effort; merges any ``extras``.
|
||||
|
||||
Ordering: this used to run under the registry lock, which serialised it for
|
||||
free. Phase 2a moved the mirror outside the lock to keep the transaction
|
||||
short, so a slow flush could otherwise land after a newer one and leave this
|
||||
file behind the registry. When the caller supplies ``transition_seq`` the
|
||||
write is skipped if the file already records a newer transition, which
|
||||
restores the sequential consistency the lock used to provide.
|
||||
"""
|
||||
try:
|
||||
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
|
||||
|
||||
record: Dict[str, Any] = {"job_id": job_id, "status": status, "updated_at": _utcnow()}
|
||||
record.update(extras)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
|
||||
Reference in new issue
Block a user