8d7e010f9f4a95e50c8dd63b78a867e990ff9d4e
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
01b465f50c |
fix(issue-5): bind the auto-completion decision atomically to its attempt
Two findings from round 2, both reproduced before being accepted. R2 -- attribution was compared before the terminal write and nothing tied the two together. A job could complete, be re-dispatched and claimed again in that window, and evidence from the first attempt then satisfied a status check and finished the second one. Reproduced with real transitions: committed, with the registry already on iteration 2. This corrects my own earlier reasoning, and I want to be plain about it. Last round I argued that the attempt guard could live entirely in the evidence layer because that is where attempt identity lives. That holds for comparing evidence to a dispatch, and it does not hold for atomicity: a precondition can only be atomic with the commit if the transaction can read it under its own lock. So the identity now lives in the record. It is an opaque dispatch token, not the iteration counter. That distinction is the part of the earlier rebuttal that still stands: iteration already means the loop round, and comparing a dispatch attempt against it produces a guard that is vacuous when they agree and wrong when they diverge. A token has no competing meaning. It is minted when an attempt begins -- claim and redispatch -- and the gate passes the token it decided against, so any intervening re-dispatch refuses the commit. R4 -- the intent identity was being emitted but nothing consumed it. A replayed notification is re-published with a fresh wire sequence, so the monotonic guard cannot recognise it; to that guard a retry looks new. The subscriber now deduplicates on the stable intent identity, leaving the sequence guard as the rule for ordinary events. Across a restart the disk fallback resolves terminal status and a duplicate terminal is refused as an idempotent ack, so a replay after restart cannot double-apply either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
d87674679d |
docs(issue-5): correct the phase 2b operations note and the stale references
The important correction is an honesty one, raised independently by two reviewers. The note claimed startup recovery runs in the subscriber. It does not: nothing in production imports the gate or the recovery function, and there is no per-job opt-in path, so the flag is currently a no-op in the live loop. Phase 2b delivers a verified library and gate; wiring it in belongs to phase 3, and the note now says so and lists what wiring requires. Also records the checks that are now mandatory rather than caller-dependent, that the rollback switch disables auto-completion too, that backend keys match exactly, that bookkeeping may touch one field, that settling is conditional on the intent actually sent, and why the intent's identity travels in the payload rather than relying on the transport sequence. Fixes three stale references: task.md named the pre-move path for finalize, the loop package description still described itself as shadow-only, and a comment in mqtt_common still pointed at the engine's original location. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
cb7ed1aa38 |
fix(issue-5): close the phase 2b gate holes found by all three reviewers
Seven blocking items across three reviews, every one reproduced here before being accepted. Two themes run through them: a gate whose strictness depended on what the caller remembered to pass, and a boundary that trusted its only current caller instead of enforcing its own rule. Independent verification is no longer optional. Omitting the report directory skipped the disk check entirely and handed the decision back to the collector's own flags -- the self-certification this design exists to prevent. It is now a refusal, as is an empty directory or a job with no recorded dispatch. Evidence is now bound to its target. The policy compares a scenario against the dispatch context carried inside that same scenario, which is self-consistent by construction, so one job's evidence could complete another. The gate now checks the job, attempt, session and pane generation against the recorded dispatch. A reviewer with no verdict token is no longer completed. Phase 0 made this a safety contract and run_loop reads a missing token as NOT PASS, so inferring completion first would start a correction round against a review that was never finished. The requirement applies to reviewers only. Bookkeeping may write one field. A status-free request was an unrestricted merge on a terminal record: it could rewrite the prompt and the session. Settling is also conditional now on the exact intent that was sent, because publishing happens outside the lock and a newer intent can legitimately land mid-flight; clearing unconditionally dropped a notification nobody had sent. The intent's own identity travels in the payload, since the transport sequence cannot identify a retry of the same intent. Side-effect evidence must be evidence. A kind with no reference names something without evidencing it, and the CLI parser could not be the only check because the API and hand-written manifests bypass it. Also honours MAM_ENGINE_CAS=0, which the operations note already promised disables auto-completion, matches backend keys exactly rather than by prefix so a future name cannot inherit evidence never collected for it, records an absolute report path, and imports the notifier at module scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
12cba25c18 |
fix(issue-5): keep finalize inside delegation and refresh the CLI golden
Two failures the full suite caught, both mine. finalize.py started in lib_py/loop, which made publish_event import a sibling package. That is the rule phase 1 wrote down and phase 2a already made me move the engine for: under a frozen loop, delegation is bound to one tree while the parent may be another, so reaching sideways mixes them. The manifest is written into the job's own report directory and the CLI that declares it is a delegation entry point, so finalize belongs there. Second time on the same rule, and the guard caught it both times. The publish_event --help golden no longer matched, because phase 2b intentionally added the finalization options. The diff is exactly those three options and nothing else, so the capture is refreshed rather than the assertion weakened, and the test now states when a refresh is legitimate: an intended extension, never drift nobody meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
8adbe2245c |
docs(issue-5): phase 2b scenarios, operations note and task checklist
Three scenarios join the contract set, so the fifteen vectors now cover an unproven side effect, an undecidable attribution, and an agent whose state goes unreadable in the window between finalization and notification. All fifteen match their expected decision. The side-effect case needed a decision about layering. Authoritative enforcement belongs in manifest verification, which has the job record and therefore knows what was declared; the policy honours an explicit negative from the collector so the case is expressible as a vector. Absence is not read as proof there, because the manifest check is the layer that fails closed on missing evidence. The operations note records why the agent declares and the reader re-derives, the three gates and why the backend allowlist can only be narrowed, the notification transaction, and the rollback. It also records a deviation from my own plan. §D2 asked for reviewer sign-off before promoting an attempt id into the record, since that changes a contract phase 1 froze. Measurement during implementation showed the change was not needed: the attempt guard already lives in the evidence layer, where attempt identity actually is, so auto-completion passes the engine a status precondition instead. The schema is untouched, the frozen contract holds, and a characterization test fails the moment a record starts carrying an attempt id, which is when to revisit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
803ba43884 |
feat(issue-5): auto-completion gate and durable pending notification
The gate does not re-decide anything. Phase 2a already decides, fail-closed and against contract-pinned vectors; this supplies real evidence to that decision and commits only if three independent gates agree: the global switch is on, the backend has observed evidence that it reaches a quiescent state, and the job itself opted in. Default is off. The backend allowlist is evidence-based rather than optimistic. Inferring "finished" from stillness requires stillness to have been observed, and only three of six backends have that. An operator may narrow the list; a name that was never observed is dropped rather than trusted, so it cannot be widened by configuration. Everything else holds. An unreadable manifest, a report rewritten after finalization, missing evidence for a declared side effect, a policy that raises: all refuse. "Could not decide" is never "done". A late explicit terminal wins, because the boundary refuses the inferred one -- that is the boundary working, not an error in the gate. Notification intent is committed in the same atomic write as the state, so a crash between committing and publishing leaves a record that still says the world has not been told. Recovery re-sends; duplicates are already dropped by sequence. A failed send deliberately leaves the intent pending, because an unsent notification that looks sent is worse than one that is retried. Two fixes this surfaced. Settling used a request that terminal records refuse, which made an owed notification impossible to ever settle; settling is now a status-free bookkeeping request, valid in any state. And the phase 2a guard against shell references was matching hashlib.sha256 as a substring, so it now requires a word boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
3ea5757cfe |
feat(issue-5): report finalization manifest and side-effect evidence
The agent declares that its work is finished; the reader re-derives the hash from the file rather than trusting the number in the declaration. A manifest the watcher wrote itself would attest only that the watcher saw a stable file, which is the self-certifying evidence shape rejected earlier in this issue. Finalizing promotes a staged report by atomic rename before writing the manifest, so a reader cannot observe a half-written report as final. Rewriting the report afterwards invalidates the declaration immediately, because the recomputed hash no longer matches -- a new body needs a new finalization. A job that declared side effects must show evidence for every one of them. Partial evidence is treated as none: the work is not known to be complete. publish_event gains --finalize-report, --side-effect and --attempt-id, and does the finalization before publishing. A `completed` event whose report is still being written would invite exactly the partial adoption the contract forbids, so a failure there fails the publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
cb575fa851 |
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 |
||
|
|
56d08efa95 |
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 |
||
|
|
964abc6965 |
feat(issue-5): shadow watcher that logs would-complete and cannot write
policy.decide is a pure function so the phase-0 scenario fixtures can be fed to it verbatim. Those seven decisions survived three-reviewer scrutiny, so they are the contract rather than a fresh set of rules, and the test asserts against expected_policy.decision directly. Order inside the policy matters. Transport doubt and staleness invalidate the evidence itself, so they are answered before anything is inferred from it; a blocked agent is waiting for a human and can never be completed; attribution including the execution anchor comes before quiescence, because idle is not evidence of finishing when a session is reused; and a report must be finalized, never merely paused, since a debounce is a candidate filter and not an authority. The shadow runner is never handed the engine, so it has no route to a write. That is the structural half of P2A-03. The measured half hashes every file under the registry directory around a full run and asserts it is unchanged, which is a stronger claim than checking a status field. Adds the five scenarios P2A-04 requires: a raced double dispatch, a reused attempt with no anchor, a mid-write report, an unknown status, and a pane restarted mid-flight. They pass phase 0's own fixture validator, which is what keeps them honest. The phase-0 test that pinned exactly seven scenarios now requires the seven to still exist rather than forbidding growth. Also pins the two phase 1 handoffs as dormant: nothing here spawns a daemon, so preexec_fn stays unreachable, and nothing calls a frozen shell helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
b49af56c3c |
feat(issue-5): herdr agent wait watcher with reconnect backoff and a shadow store
watcher wraps one `herdr agent wait` barrier and observes only; it never concludes. The live contract is encoded rather than described: the timeout parameter is named timeout_ms because herdr counts milliseconds, --until is always passed explicitly since omitting it silently drops `working`, and a non-zero exit is read from stderr because stdout is empty by contract. A timeout counts as healthy. The socket answered and the state simply did not change; treating that as a failure would make an idle agent look unreachable. Only a matched snapshot becomes evidence, so a timeout can never be mistaken for an observation of agent state. Backoff doubles from 1s to a 16s ceiling and drops the cached snapshot on failure. Reasoning from state captured before a gap is exactly what would make a disconnect look like a completion, which is why the contract answer there is hold_reconnect. store keeps dispatch and attribution evidence outside the registry. The job record carries none of attempt_id, agent_session_uuid or pane_process_generation, phase 1 froze its schema, and this phase may not write the registry at all. Keeping the evidence here lets shadow mode carry full attribution while the registry stays untouched; promoting these fields is a 2b decision. The execution anchor starts false because acknowledging a dispatch is not evidence that the attempt ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |
||
|
|
2532e78453 |
feat(issue-5): single CAS transition boundary for every job-status write
engine.transition is now the only writer of job status. It implements the target table in state-transitions.md §3: claim, non-terminal metadata, explicit terminals, idempotent re-receipt, refusal of conflicting or late signals with an audit entry, stale-attempt rejection, and deadline timeout. Two write paths become one. update_job_status merged fields unconditionally; pick_pending hand-rolled a correct pending->running swap plus its own audit mirror. Neither was wrong alone, but a rule added to one silently missed the other, which is the shape that produced the phase 1 transport bugs. Both keep their signatures and their preserved legacy bodies behind MAM_ENGINE_CAS=0. A merged `status` value is routed to the request that owns it, so a raw field write can no longer slip past the table. Getting that wrong first was caught by the characterization tests: a metadata merge was overwriting the caller's status with the pre-merge value. Lock discipline is now enforced, not described. The lock covers a decision and one record write; audit mirroring is queued and flushed after release, carrying a monotonic transition_seq so ordering survives the move. A test watches for subprocess, socket and sleep calls while the lock is held and asserts none. Structural correction to my own plan, caught by a phase 1 guard: the plan put the engine in lib_py/loop, but delegation would then have had to import a sibling, which is exactly what mixes trees under a frozen loop. The engine belongs with the registry writers, so it lives in lib_py/delegation and loop consumes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd |