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:
1 parent
3b134e3358
commit
cb575fa851
6 files changed
+298
-48
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user