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
@@ -817,7 +817,9 @@ def test_r1_policy_rejects_evidence_that_contradicts_the_dispatch(field, value):
|
||||
scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8"))
|
||||
assert policy.decide(scenario).decision == "candidate_approved"
|
||||
mutated = copy.deepcopy(scenario)
|
||||
mutated["timeline"][2]["payload"][field] = value
|
||||
# The collector envelope is where an identifier legitimately lives; a native
|
||||
# payload is not an identifier source at all.
|
||||
mutated["timeline"][2]["collector_attribution"][field] = value
|
||||
decision = policy.decide(mutated)
|
||||
assert decision.decision == "hold", "{}={!r} must not be approved".format(field, value)
|
||||
assert field in decision.reason
|
||||
@@ -887,3 +889,173 @@ def test_r3_an_unknown_request_is_refused(mam_sandbox, tmp_path, monkeypatch):
|
||||
res = engine.transition(job_id, "not_a_request", registry_dir=reg, fields={"x": 1})
|
||||
assert res.committed is False and "unknown request" in res.reason
|
||||
assert "x" not in _load(reg, job_id)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 9. Cross-review round 2 regressions (job 57166ae2)
|
||||
#
|
||||
# F-C (claude): an idempotent ACK was reported as a refusal, so the wrapper's
|
||||
# approved path aborted under set -e before its validation hook.
|
||||
# F-D (claude): routing around update_status lost the status vocabulary check.
|
||||
# R1 (codex): absence of evidence still approved; only present identifiers
|
||||
# were compared.
|
||||
# R4 (codex): the mirror's ordering guard was a check followed by a write with
|
||||
# no lock between them.
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.mark.parametrize("mode", CAS_MODES)
|
||||
def test_fc_idempotent_finalize_is_success_not_refusal(mam_sandbox, tmp_path, monkeypatch, mode):
|
||||
"""F-C: the wrapper finalises an approved job that is already completed.
|
||||
|
||||
It runs under `set -e`, so reporting failure there aborts the success path
|
||||
before the validation hook and the final stdout line."""
|
||||
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import mqtt_common, registry
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
mqtt_common.update_job_status(job_id, reg, _source="publish_event", status="completed")
|
||||
|
||||
rc = registry.main(["--registry-dir", reg, "status", "--job", job_id, "--set", "completed"])
|
||||
assert rc == 0, "re-affirming a state that already holds is an ack, not a failure"
|
||||
assert _load(reg, job_id)["status"] == "completed"
|
||||
|
||||
|
||||
def test_fc_a_genuine_conflict_is_still_reported(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""The F-C fix must not soften a real refusal back into silence."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import mqtt_common, registry
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
mqtt_common.update_job_status(job_id, reg, _source="publish_event", status="completed")
|
||||
rc = registry.main(["--registry-dir", reg, "status", "--job", job_id, "--set", "cancelled"])
|
||||
assert rc == 4
|
||||
assert _load(reg, job_id)["status"] == "completed"
|
||||
|
||||
|
||||
def test_fc_engine_marks_an_idempotent_result(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import engine
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
engine.transition(job_id, "completed", registry_dir=reg)
|
||||
again = engine.transition(job_id, "completed", registry_dir=reg)
|
||||
assert again.committed is False and again.idempotent is True
|
||||
conflict = engine.transition(job_id, "cancelled", registry_dir=reg)
|
||||
assert conflict.committed is False and conflict.idempotent is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", CAS_MODES)
|
||||
def test_fd_invalid_status_is_rejected_in_both_modes(mam_sandbox, tmp_path, monkeypatch, mode):
|
||||
"""F-D: the vocabulary check must survive the routing, especially on the
|
||||
rollback path where the engine is not there to catch it."""
|
||||
monkeypatch.setenv("MAM_ENGINE_CAS", mode)
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import registry
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
before = _dir_fingerprint(Path(reg))
|
||||
rc = registry.main(["--registry-dir", reg, "status", "--job", job_id, "--set", "bogus"])
|
||||
assert rc == 1, "an invalid status is a usage error, not a CAS refusal"
|
||||
assert _dir_fingerprint(Path(reg)) == before, "an invalid status must not be written"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", [
|
||||
"job_id", "attempt_id", "agent_session_uuid", "pane_process_generation",
|
||||
])
|
||||
def test_r1_missing_attribution_evidence_holds(field):
|
||||
"""R1: absence of proof is not proof. Every identifier must be supplied."""
|
||||
import copy
|
||||
from lib_py.loop import policy
|
||||
scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8"))
|
||||
assert policy.decide(scenario).decision == "candidate_approved"
|
||||
|
||||
mutated = copy.deepcopy(scenario)
|
||||
for item in mutated["timeline"]:
|
||||
(item.get("collector_attribution") or {}).pop(field, None)
|
||||
(item.get("payload") or {}).pop(field, None)
|
||||
decision = policy.decide(mutated)
|
||||
assert decision.decision == "hold", "missing {} must not be approved".format(field)
|
||||
assert field in decision.reason
|
||||
|
||||
|
||||
def test_r1_missing_report_manifest_holds():
|
||||
import copy
|
||||
from lib_py.loop import policy
|
||||
scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8"))
|
||||
mutated = copy.deepcopy(scenario)
|
||||
for item in mutated["timeline"]:
|
||||
(item.get("payload") or {}).pop("report_manifest", None)
|
||||
decision = policy.decide(mutated)
|
||||
assert decision.decision == "hold" and "manifest" in decision.reason
|
||||
|
||||
|
||||
def test_r1_identifiers_inside_a_native_payload_are_not_evidence():
|
||||
"""A MAM field cannot appear in a native Herdr payload, so one found there is
|
||||
either fabricated or a contract violation. Either way it proves nothing."""
|
||||
import copy
|
||||
from lib_py.loop import policy
|
||||
scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8"))
|
||||
mutated = copy.deepcopy(scenario)
|
||||
dispatch = mutated["dispatch_context"]
|
||||
herdr_item = [i for i in mutated["timeline"] if i["source_kind"] == "herdr"][-1]
|
||||
herdr_item.pop("collector_attribution", None)
|
||||
for field in ("job_id", "attempt_id", "agent_session_uuid"):
|
||||
herdr_item["payload"][field] = dispatch[field]
|
||||
assert policy.decide(mutated).decision != "candidate_approved"
|
||||
|
||||
|
||||
def test_r4_overlapping_mirror_flushes_cannot_lose_the_newer_state(mam_sandbox, monkeypatch):
|
||||
"""R4: a sequence number orders nothing unless compare and write are atomic.
|
||||
|
||||
Interrupts a flush after it has read, lets a newer one complete, and requires
|
||||
the newer state to survive."""
|
||||
import threading
|
||||
from lib_py.delegation import mqtt_common
|
||||
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
logs = mqtt_common.get_logs_dir()
|
||||
job_id = "r4probe"
|
||||
mqtt_common.update_logged_status(job_id, "pending", logs, transition_seq=0)
|
||||
path = mqtt_common.job_log_path(job_id, mqtt_common.STATUS_FILENAME, logs)
|
||||
|
||||
real_read = Path.read_text
|
||||
state = {}
|
||||
|
||||
def intercept(self, *a, **k):
|
||||
text = real_read(self, *a, **k)
|
||||
if str(self) == str(path) and "fired" not in state:
|
||||
state["fired"] = True
|
||||
thread = threading.Thread(
|
||||
target=mqtt_common.update_logged_status,
|
||||
args=(job_id, "completed", logs), kwargs={"transition_seq": 2})
|
||||
thread.start()
|
||||
state["thread"] = thread
|
||||
thread.join(timeout=0.5)
|
||||
state["blocked"] = thread.is_alive()
|
||||
return text
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", intercept)
|
||||
mqtt_common.update_logged_status(job_id, "running", logs, transition_seq=1)
|
||||
monkeypatch.undo()
|
||||
state["thread"].join(timeout=5)
|
||||
|
||||
assert state.get("blocked") is True, "the competing flush must serialise, not race"
|
||||
final = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert final["status"] == "completed" and final["transition_seq"] == 2
|
||||
|
||||
|
||||
def test_r4_each_mirror_write_uses_its_own_temp_file(mam_sandbox, monkeypatch):
|
||||
"""A shared temp name lets concurrent writers clobber each other pre-rename."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import mqtt_common
|
||||
source = Path(SKILLS / "lib_py" / "delegation" / "mqtt_common.py").read_text(encoding="utf-8")
|
||||
body = source[source.index("def update_logged_status"):source.index("def init_job_log")]
|
||||
assert "mkstemp" in body, "the mirror must not reuse one fixed .tmp path"
|
||||
logs = mqtt_common.get_logs_dir()
|
||||
mqtt_common.update_logged_status("tmpprobe", "running", logs, transition_seq=1)
|
||||
leftovers = list((Path(logs) / "tmpprobe").glob("*.tmp"))
|
||||
assert not leftovers, "temp files must be renamed away, not left behind: {}".format(leftovers)
|
||||
Reference in new issue
Block a user