Skip to content

fix(claude-local): #5747 follow-ups: bridge state dir recorded once, a lost turn answered at once, publish I/O retries, lost park reply converges - #5769

Merged
sonichi merged 16 commits into
mainfrom
fix/claude-local-5747-cleanup
Oct 21, 2026
Merged

sonichi merged 16 commits into
mainfrom
fix/claude-local-5747-cleanup

Conversation

@sonichi

@sonichi sonichi commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Refs #5747 — the remaining follow-ups of the local Claude Code shape, each small and pinned. Items 2 (#5767), 6 (#5728 → #5763), 1/8/9/13 (fixed on #5695) are not touched.

Per item

Item What Done here
3 verify's bridge row read only Sparrow's default state dir Fixed. install records BRIDGE_STATE_DIR once (the dir beside the root for the bridge it wires; --bridge-state-dir for a bridge you run, default Sparrow's state dir) and derives HITL_STORE from it; wire_bridge writes the unit/declaration from that value; verify's bridge row and the hitl-store row read it. A config from before the key reads the HITL store's parent (as the README then said); one naming neither, the dir beside the root (what sparrowd recomputes for a declaration). Pinned: ConnectAgentRoot.test_install_records_the_bridges_state_dir_once_and_verify_reads_the_bridge_there.
10 DECISIONS entries without a test Nothing left. All three entries are pinned in InboxCli.test_a_store_write_that_fails_is_a_retry_by_disposition_and_a_decision_stays_final (TurnRefused final, the admission Refused, AttemptInProgress final); no change.
11 forwarder crash semantics Left for the owner; documented. New Limits bullet in the adapter README: survive-and-adopt holds for the launcher's own death; the run --launchd forwarder's death (witnessed with SIGKILL) is a stop and a resume by id (AbandonProcessGroup false), not an adoption. Which process the pin kills is the owner's call.
12 descriptor recorded, no verified blob, Mod restarted Fixed. A Mod that lost its offers inside one generation claims every listed task afresh, and a claim it already drove past its call is one it can never finish. The decision for such a TURN_CONFIRMED claim is one Lifecycle method, close_turn, which _recover_generation and inbox.lost_turn_answered both call (round 1): a descriptor whose blob verifies is finished (finish_staged) and published; a blob missing or not matching its descriptor is answered as interrupted, the detail naming the mismatch, never those bytes; an I/O failure while finishing a verifying blob is parked (claim as it was; the verified body publishes on the next call), never an interrupt. At RESULT_STAGED/PUBLISHED the staged answer is published. CALLING, a turn confirmed with nothing accepted, and another generation's claim stay as before. Pinned through each caller: InboxCli.test_a_claim_the_mod_lost_past_its_call_is_finished_or_interrupted_at_once (mismatch detail, single-EIO retry, delegation to close_turn), LocalClaimTest.test_close_turn_is_the_one_decision_…, …test_generation_recovery_answers_a_mismatched_committed_blob_as_interrupted_through_close_turn, …test_generation_recovery_retries_a_verifying_blob_whose_finish_failed_on_io_never_interrupting (both copies).
14 compounded-I/O liveness (review 5581413936) Fixed. The identical park again after one that settled but whose reply was lost (lock release failed, claim unreadable) answers settled, parked naming the protocol failure only when that is what it delivered; nothing is published twice, so the Mod retires the offer instead of retaining parkPending until a restart. No bound was asked for; the retry now converges on the next call. Pinned: InboxCli.test_a_park_whose_reply_was_lost_after_it_settled_answers_settled_on_the_identical_retry.
15 test title overstates its pin (review 5581756714) Fixed. The harness fake counts a verb named twice as two failures; the test injects ['publish', 'publish'], asserts the turn-end publish ran and failed (2 publish calls, no result), then the scan's retry delivered (3 calls, 1 publication). The one-failure form now fails.
16 unwritable results dir surfaces as "publication parked" Fixed. publish_and_settle(task_id, io_raises=True) turns a publication I/O failure into the StateIOError the claims-step boundary answers as the pre-commit retry (the step did not commit (…); retried, retry: true), so the reply tool says call ag2space_reply again with the same text; park of a staged answer gets the same. The default still parks (cloud adapter, recovery). Pinned: InboxCli.test_a_publication_that_fails_on_io_is_the_pre_commit_retry_with_the_call_again_hint, LocalClaimTest.test_publish_and_settle_is_parked_for_a_phase_it_did_not_advance.
4 bypass-permissions dialog on a new config dir Left for the owner (a per-config-dir setting; revisit if fresh config dirs become common).
5 an agent can symlink its own tasks/ at another's Left for the owner (same OS user; fix only if agents run as different users).
7 not witnessed live (stall card from a held turn, terminal-75 across two throttle intervals, systemd install) Left for the owner (pinned by tests).

Review round 1 (Susan 5588475159, Qingyun 5588548239), one push c2511c5d → 9ff71523

  • Blocker (both): the TURN_CONFIRMED decision was split between _recover_generation and lost_turn_answered and disagreed on a committed blob that does not verify (recovery parked it with the descriptor kept; the claim path interrupted it), and a single EIO in _commit_blob's temp write with the final blob verifying sent the claim path to interrupt while recovery delivered the answer (a delivered wrong body). Now Lifecycle.close_turn (both copies, drift test green) is the one decision both callers use, with Qingyun's rule: I/O while finishing a verifying blob is always a retry; interrupt only when the blob does not verify. Policy choice for the mismatch: interrupted (never kept parked forever); the suite's old pin "a mismatched blob parks in recovery too" is updated accordingly.
  • Susan 2 (non-blocking): park now compares the recorded descriptor against the failure body in every branch (TURN_CONFIRMED, RESULT_STAGED, SETTLED), so the Mod's log says "the protocol failure" when that is what landed. Pinned in …test_a_publication_that_fails_on_io… (park → I/O retry → identical park answers parked: <detail>).
  • Susan 3 (non-blocking): for a config from before BRIDGE_STATE_DIR, verify prints one INFO line naming the store and the reinstall instead of a row checked against a value derived from itself; README says so. Pinned in ConnectVerify.test_verify_passes_every_part_and_fails_the_broken_one.
  • Qingyun nit: publish said "the step did not commit" after the rename had landed. _publish now raises RenamedNotDurable for a failure after the rename; publish_and_settle(io_raises=True) turns it into PublishedNotDurable, and the CLI answers retry as "the result landed, its durability unknown (…)"; the next publish settles on the result it finds (won). Pinned in the same io test.

Before (at c2511c5d, new/changed tests copied into a detached worktree):

test_task_lifecycle.py (4 selected): FAILED (failures=1, errors=3)
  ERROR …recovery_answers_a_mismatched_committed_blob…   AttributeError: 'Lifecycle' object has no attribute 'close_turn'
  ERROR …recovery_retries_a_verifying_blob_whose_finish_failed_on_io…   AttributeError: … no attribute 'close_turn'
  ERROR test_close_turn_is_the_one_decision…            AttributeError: … no attribute 'close_turn'
  FAIL  test_same_different_corrupt_and_duplicate_candidates   ('TURN_CONFIRMED', …) != ('SETTLED', 'turn confirmed, no result (the committed result does not match its descriptor)')
test_claude_code_adapter.py (3 selected): FAILED (failures=3)
  FAIL  …the_mod_lost_past_its_call…     detail "…was lost" != "…was lost (the committed result does not match its descriptor)"
  FAIL  …fails_on_io…                    'the result landed, its durability unknown' not found in 'the step did not commit (StateIOError: … cannot publish …)'
  FAIL  test_verify_passes_every_part…   'hitl' unexpectedly found (row derived from the store itself)
Qingyun's single-EIO repro through inbox.claim, isolated at c2511c5d:
  (rc, retry, result body) == (1, None, '[AG2 Space] This attempt was interrupted…')   # a wrong body delivered
  at 9ff71523: (1, True, None), then the next claim delivers 'the verified answer'

After (HEAD 9ff71523): adapter 218 OK · lifecycle 260 OK (both copies) · Mod harness 67/67, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8, wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh clean.

Review round 2 (Kewei, at c2511c5d), one push 9ff71523 → e0597636

  • Blocker: Lifecycle.interrupt_turn() staged the interrupted body, saved the claim at OUTCOME_UNKNOWN, then at RESULT_STAGED. A first claim.json rename that landed with a failed directory fsync made ClaimStore.save() raise StateCommitted, the live claim answered hard, OUTCOME_UNKNOWN stayed durable, and the next same-generation claim's lost_turn_answered() gave the final refusal "attempt 1 is OUTCOME_UNKNOWN": the Mod retired the task with no result until the generation died. Now, in the one lifecycle owner (both copies, drift test green):
    • interrupt_turn() lands in RESULT_STAGED in one save (the body is durable before it), so a rename that lands always leaves a publishable claim, never an intermediate phase;
    • Lifecycle.resume_outcome() is the one move for a local claim committed at OUTCOME_UNKNOWN (left by an older write, or by recovery's own two-step CALLING path): _recover_generation calls it, and the live reclaim (lost_turn_answered) resumes it and publishes the durable interrupted body instead of refusing. The next claim never retires it.
    • The 9ff71523 rules are unchanged (a verifying blob is never interrupted; I/O while finishing is a retry).
  • Pins: InboxCli.test_an_interruption_whose_claim_rename_landed_without_its_directory_fsync_is_resumed_by_the_next_claim (his repro through inbox claim: EIO at the dir fsync after the claim.json rename → first claim exit 3, hard, phase RESULT_STAGED; the next claim publishes the interrupted body, SETTLED; plus a committed OUTCOME_UNKNOWN resumed and published by the next claim), LocalClaimTest.test_interrupt_turn_is_one_durable_transition_and_a_committed_outcome_unknown_is_resumed (both copies; recovery delegates to resume_outcome; a cloud claim is never resumed here), and the Mod harness test "a lost turn whose interruption landed before its directory fsync failed is published by the next claim, never retired" (the hard claim keeps the task and closes intake, intake reopens, the next claim publishes once, nothing submitted). Mutating the harness fake to leave OUTCOME_UNKNOWN fails that test.

Before, the new tests copied into detached worktrees:

# c2511c5d and 9ff71523 (identical)
InboxCli.test_an_interruption_whose_claim_rename_landed…   FAIL  (3, True, 'OUTCOME_UNKNOWN', 1) != (3, True, 'RESULT_STAGED', 1)
LocalClaimTest.test_interrupt_turn_is_one_durable_transition…   FAIL  (1, 'OUTCOME_UNKNOWN', None, …) != (1, 'RESULT_STAGED', None, …)
his repro, behavior only (phase assertion removed):
  first claim:  rc 3, hard, phase OUTCOME_UNKNOWN
  second claim: rc 1 {'refused': '…: attempt 1 is OUTCOME_UNKNOWN'}; result exists: False
# e0597636
  first claim:  rc 3, hard, phase RESULT_STAGED
  second claim: rc 1, refused "…: its staged answer is published now; only ABSENT may be submitted"; the interrupted body published, SETTLED

After (HEAD e0597636): adapter 219 OK · lifecycle 262 OK (both copies) · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd OK, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 3 (Susan and Kewei request changes at e0597636; Qingyun agrees), one push e0597636 → 7e09b8d6

  • Blocker, recovery path (Susan; Kewei's 2): close_turn finished a verifying blob through finish_staged, which committed the blob again. One transient EIO in the single recovery pass a hold start runs left the claim TURN_CONFIRMED with no result. The new generation's live claim then got the final refusal "attempt 1 is TURN_CONFIRMED", so the answer waited for the next restart (at c2511c5d it published). Now:
    • Lifecycle.finish_verified moves the claim to RESULT_STAGED with the claim save alone, as main did. The blob is final, and it shares claim.json's directory, so that save's directory fsync makes the blob durable too. close_turn uses it (both copies, drift test green).
    • The live claim also delivers whatever one pass left publishable for another generation: a verifying blob, a staged answer whose publication failed, or a committed OUTCOME_UNKNOWN. Interrupting a turn stays recovery's job.
  • Blocker, in-flight mirror (Kewei's 1): with read_final() faulted after the result rename landed inside the lost claim's publication, the first claim answered retry. The next claim then took the terminal-result branch, cleared only the stuck mark and left the claim RESULT_STAGED with progress.inflight at TURN_CONFIRMED, and the detector reported a false stall. Now a claim that finds the result settles a local RESULT_STAGED/PUBLISHED claim on it (the publisher adds no second result) and drops the in-flight mirror together with the stuck mark in one write.
  • Folded (Qingyun A): lost_turn_answered maps PublishedNotDurable to the same retry the publish verb gives.
  • Folded (Susan 2 / Qingyun B): the recover docstring no longer says a mismatched blob parks.
  • Kept: the e0597636 OUTCOME_UNKNOWN fix and the 9ff71523 rules. A verifying blob is never interrupted, and I/O while finishing is still a retry; that I/O is now the claim save's, pinned with an EIO in the claim save.

Pins (no second recovery pass anywhere):

  • InboxCli.test_one_recovery_pass_then_the_live_claim_delivers_a_dead_generations_verified_answer_once: ONE recover_dead_generation pass, then live claims. With the blob writer failing, the verified answer is published by that pass with no blob write. One whose publication failed in the pass is published by the live claim, once. One whose lineage is unknown (recovery leaves it) is published by the live claim, once.
  • LocalClaimTest.test_one_generation_recovery_pass_delivers_a_verifying_blob_with_no_blob_write (both copies). It replaces the old test that relied on two back-to-back passes.
  • InboxCli.test_a_lost_claims_publication_whose_read_back_fails_after_the_rename_settles_and_clears_in_flight: Kewei's replay. The first claim retries; the next claim settles; inflight is empty; the detector reports no stall at 601 s.
# e0597636 (new tests copied into a detached worktree)
one recovery pass, then the live claim      ERROR  results/task-dead-blob-eio.txt does not exist (TURN_CONFIRMED after the pass)
one generation recovery pass (both copies)  FAIL   (close_turn calls, blob writes) (1, 1) != (1, 0)
read_final fault, then the next claim       FAIL   'RESULT_STAGED' != 'SETTLED'
# c2511c5d
Susan's blob-EIO repro                      passes (recovery published, as she said); the test fails later on the
                                            added case: a pass-parked publication -> 'attempt 1 is RESULT_STAGED'
read_final fault, then the next claim       FAIL   'RESULT_STAGED' != 'SETTLED'

After (HEAD 7e09b8d6): adapter 221 OK · lifecycle 262 OK (both copies) · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 4 (Kewei at 7e09b8d6), one push 7e09b8d6 → 20928ff5

  • Blocker, settlement durability: Lifecycle._settle() saves SETTLED, then appends the id to settled-ids and removes the staged body. If the append faulted, the claim stayed SETTLED with no ledger entry, and nothing ever repaired it:

    • that claim answered retry;
    • the next claim cleared the progress mirror and final-refused, because its terminal branch only handled RESULT_STAGED and PUBLISHED;
    • recover_dead_generation skipped every SETTLED claim.

    The result survived and nothing was published twice, but the settlement never completed and the claim never compacted. Now, in the lifecycle owner (both copies, drift test green):

    • Lifecycle.reconcile_settled() completes a SETTLED claim's settlement: it appends the id if it is missing (SettledIds.add is idempotent) and removes the staged body. _settle ends with it, and generation recovery's re-record pass uses it.
    • The claim verb reconciles a SETTLED local claim under claims.lock, before the progress cleanup and the final refusal. A ledger I/O failure is still the pre-commit retry.
    • recover_dead_generation also brings in the generation of a SETTLED claim that is still owed its ledger entry or is due for compaction.
  • Test strengthened: the in-flight cleanup test now seeds both inflight and stuck, plus an unrelated neighbor task. It asserts both maps are cleared in one production write_progress call and the neighbor's entries are untouched.

Pins:

  • InboxCli.test_a_settlement_whose_ledger_append_failed_is_completed_by_the_next_claim_and_compacts: the append faults once, so claim Add demo video to README #1 answers retry with the claim SETTLED and no ledger entry. Claim temp: demo video upload #2 gives the LIVE_READY final refusal with the ledger entry present and the staged body gone. One recovery pass past COMPACT_AFTER then compacts the claim, and the result exists once.
  • LocalClaimTest.test_reconcile_settled_completes_a_settlement_whose_ledger_append_failed (both copies).
# 7e09b8d6 (new tests copied into a detached worktree)
ledger append faults once, then a second claim   FAIL   'task-ledger-fault' not found in set(): the settlement completed before the final refusal
reconcile_settled (both copies)                  ERROR  'Lifecycle' object has no attribute 'reconcile_settled'
both-map cleanup (strengthened test)             passes (the implementation already cleared both; now pinned)

After (HEAD 20928ff5): adapter 222 OK · lifecycle 264 OK (both copies) · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 5 (Kewei at 20928ff5; Qingyun's interrupt/refusal case), one push 20928ff5 → 0bc0c36f

  • Blocker, every settled/final report: after one settled-ids append fault, several paths still reported settled or final while the ledger entry was absent and the staged body remained:

    • publish_and_settle answered settled for a claim it found already SETTLED, so publish (and park through it) reported success;
    • a non-admitted claim and interrupt carry no generation, so neither the claim verb's terminal branch nor recovery ever repaired them;
    • interrupt reported resolved from the SETTLED phase alone.

    Now, in the lifecycle owner (both copies, drift test green):

    • publish_and_settle runs reconcile_settled before it returns settled, and settle_without_result ends with it.
    • settlement_complete says whether a settlement is whole: ledger entry present, no staged body left.
    • reconcile_unbound completes the settlement of SETTLED claims no generation owns and compacts them once due.

    In the inbox:

    • resolved() counts a SETTLED claim as final only when its settlement is whole.
    • The claim verb settles a found result for local and call-less claims before it decides finality.
    • interrupt reconciles a SETTLED claim under claims.lock.
    • recover_dead_generation runs reconcile_unbound.
  • Blocker, cross-instance idempotence: SettledIds.add now takes the ledger's own flock, re-reads the file and appends only an id that is not there yet. Two calls that loaded the ledger before either appended now record the id once. The lock is on the ledger itself, so the guarantee holds whichever caller's lock is or isn't held.

  • Vacuous body assertions: the body tests now seed a real staged file (claims/<id>/body), assert it exists after the fault and is gone after the repair. Replacing ClaimStore.unstage with a no-op (both copies) fails all three: the sweep, the ledger-fault claim test, and reconcile_settled (both copies).

Pins:

  • InboxCli.test_every_settle_path_completes_its_settlement_after_one_ledger_fault_before_reporting_final: the append faults once on each of publish, park, a non-admitted claim, interrupt, and a call-less claim left to recovery. Each first call is a retry, never final. The next call (or one recovery pass) leaves exactly one ledger line and removes the real body. The call-less claim compacts once due.
  • LocalClaimTest.test_two_lifecycles_loaded_before_either_appends_record_a_settled_id_once (both copies): two stale Lifecycles give one ledger line, and two stale SettledIds likewise.
  • LocalClaimTest.test_reconcile_settled_… and InboxCli.test_a_settlement_whose_ledger_append_failed_… now assert on a real body.
# 20928ff5 (new tests copied into a detached worktree)
settle-path sweep                    FAIL   publish #2 settled with (ledger, body) == (False, True), expected (True, False)
two stale Lifecycles (both copies)   FAIL   2 != 1 : one ledger line
reconcile_settled (both copies)      ERROR  no attribute 'settlement_complete'
ledger-fault claim test (real body)  passes (that path was fixed at 20928ff5; its body assertion is now real)
# HEAD with ClaimStore.unstage mutated to a no-op (both copies)
sweep, ledger-fault claim test, reconcile_settled (both copies)   FAIL (4 failures)

After (HEAD 0bc0c36f): adapter 223 OK · lifecycle 266 OK (both copies) · cloud-session adapter 65, contract 10, interlock 41, state-paths adoption OK (they share publish_and_settle) · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 6 (Kewei at 0bc0c36f): settlement as one invariant, one push 0bc0c36f → ec6800a1

This round stops patching individual paths and fixes settlement as one invariant.

  • The invariant: a SETTLED claim is complete when the settled-ids ledger holds its id and its staged body is gone. reconcile_settled is the single repair-then-check and now returns settlement_complete. Every reader that can report settled or final runs it first:
    • the claim and interrupt verbs, at the top of their step under claims.lock (Kewei's 3: a no-result SETTLED claim is repaired before the "attempt N is SETTLED" refusal);
    • publish and park, through publish_and_settle;
    • recovery, whose scan now selects a SETTLED claim that is not complete. That includes a ledger entry present with the body left behind (Kewei's 2), not only a missing entry.
    • A repair that fails is a retry, never a final reply.
  • The ledger: append-only lines. Under the ledger's own flock, every append and every open first cuts a torn tail (an unterminated suffix, or an unparseable last record) and makes the cut durable. The append then checks for the id and writes id + "\n" in one write, then fsyncs. A short write raises, and the next repair cuts it and appends the id again. A writer loaded before another's torn append therefore no longer merges into it (Kewei's 1). A malformed record before the tail still fails closed.

Property test: InboxCli.test_settlement_property_every_entry_path_by_every_fault_by_every_schedule_converges covers 7 entry paths × 4 faults × 3 schedules = 84 cases.

  • Entry paths: publish, park, non-admitted claim, interrupt, lost claim, recovery's no-result settle, recovery's publication.
  • Faults: the ledger append fails; the append is torn and a writer loaded before it runs next; the staged-body unlink fails; the progress-mirror write fails.
  • Schedules: the Mod retries; a restart's one recovery pass, then claims; the task file gone, so recovery only.
  • Asserts at quiescence: every final reply comes after a complete settlement; the result exists once (none for the no-result settle); the ledger holds the id once and still opens; the real staged body is gone.
  • Fault coverage: the fault fires in 75 cases. The 9 others are the mirror fault where the first call writes no mirror: the non-admitted claim's refusal and the two recovery-first entries.

Ledger unit tests (both copies) pin the torn-tail cut, the single write, and the re-append by a preloaded writer.

# 0bc0c36f: the property test fails 27 of 84 cases
torn x every entry, 19 cases (Kewei's 1)        the ledger no longer opens; most final replies reported
                                                over it (publish "settled", interrupt resolved=True, LIVE_READY,
                                                and "refusing to serve" for claims after it)
unlink x gone x {publish, park, lost,           settlement incomplete at quiescence: the body survives
  noresult, recovered} (Kewei's 2)              the recovery pass
noresult x {append, unlink} x retry,            "attempt 1 is SETTLED" reported over an incomplete settlement
  noresult x unlink x restart (Kewei's 3)
# ec6800a1: 84/84

Beyond Kewei's three, the property test found:

  • Interrupt over a corrupted ledger: at 0bc0c36, interrupt reported resolved: true while the ledger could no longer be opened (interrupt × torn × retry). Same root as his 1; fixed.
  • Body left behind, not the ledger entry: on the no-result path, an unlink failure followed by Mod retries gave the final refusal with the body still present (noresult × unlink × retry). A variant of his 3; fixed.
  • A corrupted ledger becomes a final refusal: the claim verb answers "refusing to serve" with no retry, so the Mod would retire every task. Torn appends can no longer corrupt the ledger. But a malformed record before the tail still fails closed this way, by the existing design. Not changed.

After (HEAD ec6800a1): adapter 224 OK · lifecycle 266 OK (both copies) · cloud-session adapter 65, contract 10, interlock 41, state-paths adoption OK · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 7 (Kewei at ec6800a1), one push ec6800a1 → ade6f83d

The test, fixed first. The property test threw away the first reply after each fault, and it had no fault for a SETTLED claim.json rename whose directory fsync fails. It now asserts "a final reply comes only over a complete settlement" on every reply, including the first after the fault, and adds that rename fault: 7 entry paths × 5 faults × 3 schedules = 105 cases. Run against ec6800a1, the fixed test fails 6 of 105: publish and park, each with the rename fault, under all three schedules. Each is a hard reply carrying outcome: settled while the ledger entry is missing and the staged body remains (her finding 1). The first-reply check found nothing else on the other four faults.

Fixes (both lifecycle copies, drift test green):

  1. Hard replies (finding 1): a hard (committed) publish reply, and park through it, carries outcome: settled only when the settlement is complete. Otherwise the Mod retries, and the retry completes the settlement.
  2. Cloud recovery (finding 2):
    • The generic/cloud recover() now repairs every SETTLED claim through reconcile_settled. Before, it added a missing id and left the body behind.
    • New Lifecycle.is_settled is the reader's repair-then-check; a compacted claim counts as complete.
    • The cloud SessionAdapter's "already settled" check uses it, and parks when the repair fails.
  3. Ledger repair (finding 3): only an unterminated tail is cut, the only shape a short append can leave. Any newline-terminated malformed record fails closed with its bytes untouched, as before this PR. Round 6's "or unparseable last record" is reverted.

Pins, each failing at ec6800a1:

  • the property test (6 of 105, above);
  • InboxCli.test_a_hard_publish_reply_claims_settled_only_over_a_complete_settlement (publish and park): at ec6800a1 the hard reply carries outcome;
  • test_cloud_recovery_completes_a_settlement_and_is_settled_requires_it (both copies): at ec6800a1, after recover() the settlement is still incomplete ((False, True));
  • test_any_terminated_malformed_record_is_refused_naming_its_offset_and_only_an_unterminated_tail_is_cut (both copies), including b"task-safe\nnot a task id\n": at ec6800a1, LedgerError is not raised and the record is silently cut.

Beyond her three, nothing new. A side observation: settlement_complete reads the instance's cached ledger ids, so an instance loaded before another process appended reads "incomplete" until it repairs. That direction is conservative (a retry, never a false final), so I left it.

After (HEAD ade6f83d): adapter 225 OK · lifecycle 268 OK (both copies) · cloud-session adapter 65, contract 10, interlock 41, state-paths adoption OK · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Qingyun's mutation (on ade6f83d), one push ade6f83d → e0e8dc7c

A mutation that dropped the generation guard in reconcile_unbound survived the suite. The new pin, LocalClaimTest.test_reconcile_unbound_takes_only_claims_no_generation_owns (both copies), settles three claims, all due for compaction:

  • a live-generation claim, with a staged body left;
  • an other-generation claim, with a staged body left;
  • a call-less claim.

reconcile_unbound repairs and compacts only the call-less claim and leaves the two generation-bound claims untouched. With the guard removed it fails in both copies: reconcile_settled also reaches the live and other-generation claims. Test-only change. Lifecycle is now 270 OK (both copies); drift, ruff, git diff --check and review-checks.sh --diff are clean.

Review round 8 (Kewei and Qingyun at e0e8dc7c), one push e0e8dc7c → bea43b68

Ledger durability (Kewei 1). Rule: a ledger id counts only after a successful durable sync.

  • SettledIds now keeps durable beside ids: the ids a successful fsync of the file and its directory covered, done by this process (on open, or in add).
  • add syncs an id that is visible but not yet durable, instead of trusting it.
  • settlement_complete counts only durable ids. reconcile_settled always reaches that sync before it removes the staged body.
  • Every compaction (reconcile_unbound, generation recovery, cloud recovery) runs only over a complete settlement. No body or claim is removed before a successful ledger sync, and a failed sync removes nothing and is a retry.

Cloud recovery (Qingyun a, b).

  • The cloud SessionAdapter.step checks claim_mode first. A local claim is read from the ledger and never repaired there; a cloud claim goes through is_settled.
  • Recovery repairs each SETTLED claim through _reconcile_or_skip. An I/O failure on one claim is logged once and left for the next pass, so it neither stops the start nor gets that claim compacted.

Matrix: 7 entry paths × 6 faults × 4 schedules = 168 cases.

  • New fault: the whole record lands and its fsync fails.
  • New schedule ("late"): the task file is gone and the claim is due for compaction, which is the crash-after-remove window.
  • A watch fails any case that removes a body or claim before the id's ledger sync succeeds. Faults now fire only on the case's own task: they had begun hitting other cases' claims once every repair reaches the ledger.

At e0e8dc7c the matrix fails 28 of 168: every fsync-fault cell. All 28 remove the body before a successful sync, the retry cells give a final reply over it, and two late cells also compact the claim. At bea43b68: 168/168.

Pins, each failing at e0e8dc7c:

  • test_a_visible_ledger_id_counts_only_after_a_successful_sync_before_the_body_goes (both copies);
  • test_compaction_never_runs_before_the_ledger_sync_succeeds (both copies);
  • test_cloud_recovery_skips_a_claim_whose_repair_fails_and_finishes_it_next_pass (both copies; also checks a local claim is untouched by cloud recovery);
  • StepBranchTest.test_a_local_settled_claim_is_read_never_repaired_by_the_cloud_step.

Delegation pin (Kewei 2 / Qingyun c): StepBranchTest.test_a_settled_id_with_settlement_debt_is_repaired_through_the_lifecycle_before_it_counts. It passes at e0e8dc7c, because the delegation was already correct there, and fails when step() is reverted to the bare ledger-membership check: "a repair that fails parks the task".

After (HEAD bea43b68): adapter 225 OK · lifecycle 276 OK (both copies) · cloud-session adapter 67, contract 10, interlock 41, state-paths adoption OK · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Merged main (#5767, #5777) at 27a269b5; no behaviour change of this PR.

Review round 12 (Kewei at 5a606d07), push 5a606d07 → b08c6a95

  • The step's local catch around is_settled() is gone; only contained() classifies a failure.
  • An inner StateWriteError (a real unstage failure in the settlement repair) and an inner LedgerError (a corrupt ledger met by the repair's append), both raised inside is_settled(), now stop the adapter with exit 2 instead of being parked.
  • Pinned by StepBranchTest.test_a_write_or_a_refusal_inside_the_settlement_repair_stops_the_adapter; both cases fail at 5a606d07.
  • No other local catch remained in the contained units.
  • The read-fault property still finds N = 12 reads.

Review round 11 (Susan at 27a269b5): one containment at the serve loop's boundary, push 27a269b5 → 5a606d07

The rule, at the boundary rather than at each read.

  • RelayLoop.contained(name, unit, …) wraps each per-task unit of loop work: the recovering() probe in next_ready(), and step(). The base contains nothing.
  • The cloud Adapter's override: a plain StateIOError (a state read that failed) anywhere in that unit parks the task, and the loop goes on.
  • What still stops the adapter (exit 2) is carved out by type, not by call site:
    • StateWriteError, a new StateIOError subclass that _state_io raises for every claim write, remove and publication;
    • any non-I/O StateError, i.e. a refusal on what the state says.
  • ClaimUnreadable keeps its own branches.
  • The per-read catches added in rounds 9 and 10 are gone. _phase keeps the one-read-per-park guard.

What the enumeration found beyond decide. A clean run makes N = 12 claim reads after start-up:

# Read Outcome when faulted
1 _phase probe parked
2 step load parked
3 decide parked
4 prepare parked
5 begin_call → mark_calling parked; fix needed (below)
6 record_outcome (after the provider call) stops the adapter by design (below)
7 publish_and_settle parked
8 _staged_body parked
9–12 reconcile_settled / settlement_complete parked

Two needed fixes in the lifecycle (both copies, drift test green):

  • Read 5, mark_calling: a failed read left the task in open_attempts, so the parked task's retry raised AttemptInProgress out of serve(). It now releases the attempt (CALLING was never written).
  • Read 6, the outcome record: a failed read here, after the provider was called, would have parked a counted call that this process could never finish. It is now part of that durable write (_outcome_claim raises StateWriteError), so the adapter stops and recovery answers the call at restart.

The claude-local CLI's retry text for a failed publication now names StateWriteError instead of StateIOError; behaviour is unchanged.

Pins:

  • test_every_state_read_of_a_serve_iteration_survives_one_fault: the property. A clean run records the N reads; for each k = 1..N a fresh run faults read k once. Every read except the outcome record's leaves the loop alive with one provider call and one result. The outcome record's read stops with StateWriteError, and the restart delivers with still one call and one result. A read added later is enumerated and faulted automatically. At 27a269b5 it fails reads 3–12 (10 of 12).
  • test_the_loop_boundary_parks_a_read_failure_and_stops_on_a_write_failure: the type split.
  • test_a_parked_task_is_not_read_again_until_its_park_ends: the one-read-per-park guard. At 27a269b5 it errors only because contained did not exist yet (the guard itself was already there); removing the guard makes it fail (5 reads instead of 1).

After (HEAD 5a606d07): cloud-session adapter 73, contract 10, interlock 41 · lifecycle 282 OK (both copies) · no-drift/drift PASS · claude-code adapter 238 · routine 48, relay loop 37, codex 49, hatch 178 · Mod harness 98/98, validate passed · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1, state-paths OK · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Review round 10 (Kewei and Susan at f2ca3f51), push f2ca3f51 → 95718f52 → c021ae70

The loop's own claim read. RelayLoop.next_ready() calls Adapter.recovering() → _phase() → ClaimStore.load() before any step(), and _phase caught only ClaimUnreadable. A StateIOError on that first read escaped serve() and stopped the adapter with no provider call and no result (Kewei's blocker; Susan noted the same read as a non-blocker).

  • _phase now parks the task on a StateError (one log line) and reports it as not recovering. ClaimUnreadable keeps its own branch.
  • While a task is parked its claim is not read again, so a persistent fault costs one read per park, not one per poll.
  • The loop's other reads:
    • pending and lane are file-system scans, not claim reads.
    • step()'s reads have been contained since round 9. Wrong: Lifecycle.decide inside step() was a third uncontained read. Round 11 replaces per-read handling with one rule at the loop's boundary.
    • Start-up recovery and the done-ledger load still refuse to serve on a fault, as before.

What the serve loop now survives, and which test pins which read:

  • The first claim read, Kewei's exact shape: StepBranchTest.test_serve_survives_the_first_claim_read_failing_and_publishes_exactly_one_result. The very first ClaimStore.load() of the task fails once, with no guard on who makes it. The loop stays alive, retries, makes exactly one provider call and publishes exactly one result.
  • The loop's own read, failing repeatedly: StepBranchTest.test_serve_survives_repeated_io_errors_in_the_loops_own_claim_read_and_delivers_once_it_recovers. The _phase read fails three times; the task is parked three times, then delivered with one provider call.
  • step()'s own read: StepBranchTest.test_serve_survives_an_io_error_in_the_steps_claim_read_and_delivers_once_it_recovers, renamed from round 9.

The first two fail at f2ca3f51: "refusing to serve: cannot read claim.json ([Errno 5] I/O error)" escapes serve().

After (HEAD c021ae70): cloud-session adapter 71, contract 10, interlock 41 OK · lifecycle 282 OK (both copies) · no-drift/drift PASS · ruff, git diff --check, review-checks.sh --diff clean (exit 0). The Claude Code adapter suite (225 OK) was last run at 95718f52; this round changes only cloud-adapter code and tests.

Review round 9 (Kewei and Susan at bea43b68), one push bea43b68 → f2ca3f51

  • Cloud step, I/O on the claim read (Kewei): SessionAdapter.step read the claim before its fault containment and caught only ClaimUnreadable, so a StateIOError there escaped step() and serve() and stopped the adapter.
    • The read is now contained: a StateError parks the task for a retry. ClaimUnreadable keeps its own policy.
    • The step's other pre-call read (is_settled) was already contained.
    • Writes are unchanged on purpose: a failure while writing a publication still stops the adapter with exit 2, as the existing test_a_claim_io_error_exits_2_naming_the_path_and_keeps_the_claim requires.
  • durable (Susan): it was taken from the ids a torn-tail repair re-read under the flock, so a line another writer appended after our open-time fsync, with its own fsync failing, counted as durable. Now durable is only the lines wholly within the bytes this process read before its successful fsync; a repair never promotes. Both copies; drift test green.

Pins, each failing at bea43b68:

  • StepBranchTest.test_an_io_error_reading_the_claim_parks_the_step_never_raises (also covers an is_settled ledger failure);
  • StepBranchTest.test_serve_survives_an_io_error_in_the_steps_claim_read_and_delivers_once_it_recovers (renamed in round 10): covers only step()'s own claim read; the fault is armed after the first step. Correction: at f2ca3f51 the serve loop did not yet survive an I/O error on its own earlier claim read (next_ready → recovering → _phase). Round 10 fixes and pins that;
  • SettledIdsTest.test_durable_is_only_what_this_instances_fsync_covered_never_what_a_repair_re_read (both copies), Susan's deterministic interleaving.

Defence-in-depth pins (Susan 3), which pass at bea43b68 and fail under their mutations:

  • test_settlement_complete_counts_a_ledger_id_only_once_it_is_durable fails when settlement_complete checks self.settled instead of durable;
  • test_generation_recovery_compacts_only_over_a_complete_settlement fails when generation recovery's compaction loses its gate, with the trailing ledger sync not standing guard.

Not taken: re-appending before a retried fsync (Susan 2). A second line would break the one-line-per-id ledger the property test asserts.

After (HEAD f2ca3f51): adapter 225 OK · lifecycle 282 OK (both copies) · cloud-session adapter 69, contract 10, interlock 41, state-paths adoption OK · Mod harness 68/68, validate passed · no-drift/drift PASS · bridge 19, sparrowd 10, boundaries 8 (both), wheel PASS, claude-local wheel 1 · ruff, git diff --check, review-checks.sh --diff clean (exit 0).

Where to look first

packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/inbox.py (lost_turn_answered, park, publish), task_lifecycle.py (finish_verified, close_turn, reconcile_settled, settlement_complete, reconcile_unbound, SettledIds.add, interrupt_turn, resume_outcome, publish_and_settle), inbox.py (claim's terminal branch, _clear_stuck, _settle_terminal), cli.py (bridge_state, install, wire_bridge, verify), shape.py, the adapter README (bridge paragraph, lifecycle paragraph, Limits), the harness lockFailsNow.

Feature/documentation consistency

The adapter README is updated in-PR (it is the canonical doc for this adapter; not in docs/catalog.json). docs/: N/A.

Before / after

New and changed tests over the parent (7f7f56a8, test files copied into a detached worktree), then at HEAD:

# parent
test_claude_code_adapter.py (5 selected): Ran 5 tests — FAILED (failures=2, errors=3)
  ERROR test_a_park_whose_reply_was_lost_…                       KeyError: 'outcome'  (the retry refused "staging parked")
  ERROR test_park_publishes_the_protocol_failure_once_…          KeyError: 'outcome'
  ERROR test_install_records_the_bridges_state_dir_once_…        KeyError: 'BRIDGE_STATE_DIR'
  FAIL  test_a_publication_that_fails_on_io_…                    (1, None) != (1, True)   (no retry on an unwritable results dir)
  FAIL  test_a_claim_the_mod_lost_past_its_call_…                'task-lost-blob: attempt 1 is TURN_CONFIRMED' (left for the next generation)
test_task_lifecycle.py (2 selected): FAILED (errors=2)
  AttributeError: 'Lifecycle' object has no attribute 'interrupt_turn'
  TypeError: publish_and_settle() got an unexpected keyword argument 'io_raises'
Mod harness, item 15 test with one injected failure (mutation of the committed test): 66 pass, 1 fail
  (fail) a staged answer whose publish fails at the turn's end is retried by publish alone, never parked

# HEAD (f2ca3f51)
test_claude_code_adapter.py: Ran 225 tests — OK
test_task_lifecycle.py:      Ran 282 tests — OK   (both copies)
Mod harness:                 68 pass, 0 fail      (claude plugin validate: passed with warnings; Claude Code 2.1.304 locally, CI pins 2.1.303)

Tests run at HEAD f2ca3f51 (one file at a time, env -i, caches cleared)

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py — 225 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py — 282 OK; tests/ag2-sparrow-task-lifecycle.test.py — 282 OK
  • packages/ag2-sparrow/tools/test_no_drift.py, tests/ag2-sparrow-drift.test.py — PASS (both copies byte-identical)
  • claude plugin validate ag2space-inbox, claude plugin test ag2space-inbox — 68/68
  • tests/ag2-sparrow-claude-local-bridge.test.py — 19 OK; tests/sparrowd-agent-bridges.test.py — 10 OK (it builds a config with neither key: the fallback above is what keeps it green)
  • packages/ag2-sparrow/tests/test_adapter_boundaries.py — 8 OK; test_wheel_contents.py — PASS; tests/ag2-sparrow-claude-local-wheel.test.py — 1 OK
  • ruff check clean, git diff --check clean, scripts/review-checks.sh --diff PASS

Live path

The CLI and Mod delivery path changes (items 12, 14, 16, round 2's interruption, round 3's recovery and terminal-claim paths, round 4's settlement reconcile, round 5's settled/final reports and ledger append, round 6's settlement invariant, round 7's hard-reply, cloud-recovery and ledger-tail fixes). §15 live witness at b08c6a9, claude-local path (inbox, CLI, shared lifecycle), with owner turns after each restart: #5769 (comment) — (a) normal turn delivered once; (b) inner-launcher kill then adoption, delivered once; (c) kill -9 mid-turn settles outcome-unknown with exactly one "interrupted, send again" reply. The cloud-session adapter (relay_loop serve/recover, session_adapter step reorder and is_settled repair) has no live surface on this host; it is covered by unit tests only, pending the owner's call.

Edge cases checked

An old config (no BRIDGE_STATE_DIR) and a declaration config (neither key); --bridge-state-dir with an agent root refused; a dead generation's claim left to hold's recovery; a CALLING claim and a confirmed turn with nothing accepted still wait; a lost claim whose publication parks answers retry and the next claim finishes it; a committed OUTCOME_UNKNOWN (live reclaim or recovery) is resumed and published, never refused; park after settle with another detail answers parked: false; stage after settle still refused; publish idempotent at SETTLED; publish_and_settle default still parks on I/O.

🤖 Generated with Claude Code

… dir recorded once, a lost turn answered at once, I/O at publish retries, a lost park reply converges

Item 3: install records BRIDGE_STATE_DIR (the dir beside the root for the bridge it wires, or
--bridge-state-dir for a bridge you run) and derives the HITL store from it; wire_bridge writes the
unit or declaration from that same value and verify's bridge row reads the bridge's status there, so a
bridge on another AGENT_CONNECT_STATE_DIR passes the row and projects from the store beside it. A
config from before the key reads the HITL store's parent, one naming neither the dir beside the root.

Item 12: a Mod that lost its offers inside one generation claims a task afresh; a claim it already
drove past its call is answered now instead of at the next generation: a TURN_CONFIRMED claim with a
descriptor and a verifying blob is published, one with no verifying blob is answered as interrupted
(Lifecycle.interrupt_turn, the move recovery already made, now one method), a RESULT_STAGED or
PUBLISHED claim is published. CALLING, a turn confirmed with nothing accepted and another
generation's claim are left as before.

Item 14: the identical park again after one that settled but whose reply was lost (lock release
failed, claim unreadable) answers settled, naming the protocol failure only when that is what it
delivered, so the Mod retires the offer instead of retaining it until a restart.

Item 15: the harness test named for a turn-end publish failure injects two failures, so the turn-end
publish is the one that fails; the one-failure form of the test now fails (66/67).

Item 16: publish_and_settle(io_raises=True) turns a publication I/O failure into the StateIOError the
claims-step boundary answers as the pre-commit retry, so the reply tool says call again with the
same text instead of "publication parked".

Item 11 is documented in the README's Limits (the forwarder's death is a stop and a resume, not an
adoption). Item 10's DECISIONS entries were already pinned.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonichi
sonichi requested a review from qingyun-wu as a code owner October 20, 2026 16:35
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 93%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_cloud_session/session_adapter.py (85.7%): Missing lines 201-203
  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/inbox.py (93.8%): Missing lines 200-201,269,383,523
  • packages/ag2-sparrow/ag2_sparrow/relay_loop.py (100%)
  • packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py (97.0%): Missing lines 1118,1199,1217-1219,1223
  • packages/ag2-sparrow/tests/test_claude_code_adapter.py (96.2%): Missing lines 3690,3871,4066,4071,4086,4092-4093,4132,4134,4137,4139,4143,4145,4147,4169,4272,4424-4425,4427-4428,4436-4437,4441
  • packages/ag2-sparrow/tests/test_task_lifecycle.py (100%)
  • src/task_lifecycle.py (89.3%): Missing lines 39-40,601-602,892-894,902-905,1103-1104,1159,1162,1200,1259,1385

Summary

  • Total: 1452 lines
  • Missing: 55 lines
  • Coverage: 96%

packages/ag2-sparrow/ag2_sparrow/adapters/claude_cloud_session/session_adapter.py

Lines 197-207

  197         try:
  198             rec = self.lc.claims.load(task_id)
  199         except task_lifecycle.ClaimUnreadable as e:
  200             if task_id in self.lc.settled:
! 201                 log("already settled, never called again ->", task_id)
! 202                 seen.add(task_id)
! 203                 return True
  204             return self.park(task_id, e)
  205         # A local claim is never this adapter's to repair: it only reads the ledger for one.
  206         local = bool(rec and rec.get("claim_mode") == task_lifecycle.LOCAL)
  207         # Its repair's failures are classified by contained(), never here: a read parks, a write stops.

packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/inbox.py

Lines 196-205

  196         return {"task_id": task_id, "phase": "absent"}
  197     try:
  198         # A hard reply is final only over a complete settlement; otherwise the caller's retry completes it.
  199         request = dict(request or {}, complete=lc.settlement_complete(task_id))
! 200     except Exception:
! 201         request = dict(request or {}, complete=False)
  202     return dict(COMMITTED_FIELDS.get(verb, lambda r, q: {})(rec, request), task_id=task_id, phase=rec["phase"])
  203 
  204 
  205 def _committed_reply(lc, task_id, verb, request, message):

Lines 265-273

  265         return
  266     if rec["phase"] not in (lifecycle.RESULT_STAGED, lifecycle.PUBLISHED):
  267         return
  268     if lc.publish_and_settle(task_id) != "settled":
! 269         raise Parked("%s: answered, but its claim did not settle now (%s); retried" % (task_id, lc.claims.load(task_id)["phase"]))
  270 
  271 
  272 def _mirror(root, task_id, phase, out: dict, extra=None, unstuck=False) -> dict:
  273     """The plugin record mirrors each claim's phase after the lifecycle step; a failed write marks the

Lines 379-387

  379             else "its accepted answer could not be finished; answered as interrupted now"
  380     elif phase == lifecycle.OUTCOME_UNKNOWN:
  381         # A committed intermediate state (the rename landed, its directory fsync failed): resumed, never retired.
  382         if lc.resume_outcome(task_id) != "staged":
! 383             raise Parked("%s: its recorded outcome could not be resumed now; retried" % task_id)
  384         answered = "its recorded outcome, interrupted, is published now"
  385     elif phase in (lifecycle.RESULT_STAGED, lifecycle.PUBLISHED):
  386         answered = "its staged answer is published now"
  387     else:

Lines 519-527

  519         # `parked` names what the recorded descriptor delivers: this park's failure line, or an accepted answer.
  520         failure = rec["staged"].get("sha256") == hashlib.sha256(body).hexdigest()
  521         return dict(publish(root, task_id), parked=detail if failure else False)
  522     if rec and rec["phase"] == lifecycle.SETTLED:
! 523         return dict(publish(root, task_id), parked=False)
  524     staged = stage(root, task_id, turn_id, body)
  525     out = publish(root, task_id)
  526     out.update({k: v for k, v in staged.items() if k in ("hard", "error")})
  527     return dict(out, parked=detail)

packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py

Lines 1114-1122

  1114 
  1115             def unlink(path, *a, **k):
  1116                 if str(path).endswith("/body"):
  1117                     raise OSError(5, "Input/output error")
! 1118                 return real_unlink(path, *a, **k)
  1119             patch = mock.patch.object(lc.os, "unlink", unlink) if kind == "unstage" else contextlib.nullcontext()
  1120             if kind == "ledger":
  1121                 ad.lc.settled.durable.discard("task-a")       # visible, not yet synced by this process: add() runs
  1122                 Path(ad.lc.settled.path).write_text("not a task id\ntask-a\n")

Lines 1195-1203

  1195                 real_sleep = clock.sleep
  1196 
  1197                 def bounded_sleep(secs):
  1198                     if clock.t - T0 > 3600:
! 1199                         raise RuntimeError("no result within an hour of loop time")
  1200                     real_sleep(secs)
  1201                 clock.sleep = bounded_sleep
  1202                 try:
  1203                     run_adapter(work, fake, clock=clock, once=1)

Lines 1213-1227

  1213         failures, stops = [], []
  1214         for k in range(1, n + 1):
  1215             try:
  1216                 _, fired, stopped, calls, results = run(k)
! 1217             except Exception as e:                                  # noqa: BLE001 - the property reports any escape
! 1218                 failures.append("read %d (%s): serve() raised %s: %s" % (k, clean[k - 1], type(e).__name__, e))
! 1219                 continue
  1220             if stopped:
  1221                 stops.append(clean[k - 1])
  1222             if stopped != clean[k - 1].endswith("@outcome") or (fired, calls, results) != ([clean[k - 1]], 1, ["task-a.txt"]):
! 1223                 failures.append("read %d (%s): fired %r, stopped %r, %d calls, results %r"
  1224                                 % (k, clean[k - 1], fired, stopped, calls, results))
  1225         self.assertEqual(failures, [], "N = %d reads: %r" % (n, clean))
  1226         self.assertEqual(stops, [r for r in clean if r.endswith("@outcome")], "only the outcome record's reads stop")
  1227         self.assertTrue(stops, "the run reaches an outcome record, so the stop path is exercised")

packages/ag2-sparrow/tests/test_claude_code_adapter.py

Lines 3686-3694

  3686         """An unwritable results dir: publish answers retry (the step did not commit), as the Mod's reply tool
  3687         turns into 'call ag2space_reply again'; never the soft 'publication parked'. The turn-end park of a
  3688         staged answer carries the same retry."""
  3689         if os.geteuid() == 0:
! 3690             self.skipTest("root writes an unwritable dir")
  3691         lc = inbox._lifecycle(self.root)
  3692         t, p = "task-ro-results", "task-ro-park"
  3693         self.confirmed(t)
  3694         self.confirmed(p)

Lines 3867-3875

  3867         def add_faults_once(ledger, task_id):
  3868             if not hits:
  3869                 hits.append(task_id)
  3870                 raise inbox.lifecycle.LedgerIOError("cannot append to settled-ids ([Errno 5] I/O error)")
! 3871             return real_add(ledger, task_id)
  3872         with mock.patch.object(inbox.lifecycle.SettledIds, "add", add_faults_once):
  3873             rc, out = self.verb("claim", t)
  3874         self.assertEqual((rc, out.get("retry"), hits), (1, True, [t]), out)
  3875         ledger = lambda: inbox._lifecycle(self.root).settled.ids     # noqa: E731

Lines 4062-4075

  4062                     undurable.clear()
  4063 
  4064             def unstage(store, task_id):
  4065                 if task_id in undurable and (store.dir(task_id) / "body").exists():
! 4066                     problems.append("the body removed before a successful ledger sync")
  4067                 return real_unstage(store, task_id)
  4068 
  4069             def remove(store, task_id):
  4070                 if task_id in undurable:
! 4071                     problems.append("the claim compacted before a successful ledger sync")
  4072                 return real_remove(store, task_id)
  4073             with mock.patch.object(inbox.lifecycle.os, "fsync", fsync), \
  4074                     mock.patch.object(inbox.lifecycle.ClaimStore, "unstage", unstage), \
  4075                     mock.patch.object(inbox.lifecycle.ClaimStore, "remove", remove):

Lines 4082-4090

  4082         def check(t, reply, where, problems, undurable):
  4083             """final => complete (and durable), on every reply; True when the reply is final."""
  4084             rc, out = reply
  4085             if final(rc, out) and (not complete(t) or t in undurable):
! 4086                 problems.append("%s, final %r, over an incomplete settlement" % (where, out))
  4087             return final(rc, out)
  4088 
  4089         def complete(t):
  4090             try:

Lines 4088-4097

  4088 
  4089         def complete(t):
  4090             try:
  4091                 ids = inbox.lifecycle.SettledIds(ledger_path)         # still opens: no torn or merged record
! 4092             except inbox.lifecycle.LedgerError:
! 4093                 return False
  4094             return (t in ids.ids and ledger_path.read_text().count(t + "\n") == 1
  4095                     and not (lc.claims.dir(t) / "body").exists())
  4096 
  4097         def aged(t):

Lines 4128-4151

  4128                                     if check(t, again(), "a retry", problems, undurable):
  4129                                         break
  4130                                 else:
  4131                                     if not (done and s_name == "retry"):
! 4132                                         problems.append("no final report")
  4133                                 if not check(t, self.verb("claim", t), "a later claim", problems, undurable):
! 4134                                     problems.append("a later claim is not final")
  4135                                 recovery()
  4136                     if not complete_safe(ledger_path):
! 4137                         problems.append("the ledger does not open")
  4138                     elif not complete(t) or t in undurable:
! 4139                         problems.append("settlement incomplete at quiescence")
  4140                     got = [p.name for p in self.results.iterdir() if p.name == t + ".txt"]
  4141                     body = (self.results / (t + ".txt")).read_text() if got else None
  4142                     if body != expected:
! 4143                         problems.append("result %r, expected %r" % (body, expected))
  4144                     if problems:
! 4145                         failures.append("%s x %s x %s: %s" % (e_name, f_name, s_name, "; ".join(dict.fromkeys(problems))))
  4146                     if not complete_safe(ledger_path):
! 4147                         ledger_path.write_text("".join(sorted(x + "\n" for x in real_ids(ledger_path))))
  4148         self.assertEqual(failures, [])
  4149         self.assertEqual(cases, 168)
  4150         self.assertEqual(fired_cases, 156, "every fault fired except the mirror write where the first call writes no mirror "
  4151                          "(the non-admitted claim's refusal, and the two recovery-first entries): 168 - 12")

Lines 4165-4173

  4165             def add_faults_once(led, task_id):
  4166                 if not hits:
  4167                     hits.append(task_id)
  4168                     raise inbox.lifecycle.LedgerIOError("cannot append to settled-ids ([Errno 5] I/O error)")
! 4169                 return real_add(led, task_id)
  4170             with mock.patch.object(inbox.lifecycle.SettledIds, "add", add_faults_once):
  4171                 out = call()
  4172             self.assertEqual(len(hits), 1)
  4173             return out

Lines 4268-4276

  4268             # One EIO in the claim save that moves the verified blob on; the blob itself is never written again.
  4269             if not hits and Path(path).name == "claim.json":
  4270                 hits.append(path)
  4271                 raise OSError(errno.EIO, "I/O error")
! 4272             return real_write(path, data)
  4273         with mock.patch.object(inbox.lifecycle, "write_durable", claim_save_eio_once), \
  4274                 mock.patch.object(inbox.lifecycle.Lifecycle, "_commit_blob", side_effect=OSError(errno.EIO, "x")) as blob:
  4275             rc, out = self.verb("claim", t)
  4276         self.assertEqual((rc, out.get("retry"), len(hits), blob.call_count), (1, True, 1, 0), out)

Lines 4420-4432

  4420 def fd_path(fd):
  4421     """The path an open fd names (macOS F_GETPATH, Linux /proc), or '' when it cannot be read."""
  4422     try:
  4423         if sys.platform == "darwin":
! 4424             import fcntl
! 4425             return os.path.realpath(fcntl.fcntl(fd, 50, b"\0" * 1024).split(b"\0", 1)[0].decode())
  4426         return os.path.realpath(os.readlink("/proc/self/fd/%d" % fd))
! 4427     except OSError:
! 4428         return ""
  4429 
  4430 
  4431 def complete_safe(path):
  4432     """Whether the ledger still opens (a corrupted one is reset so later property cases stay independent)."""

Lines 4432-4445

  4432     """Whether the ledger still opens (a corrupted one is reset so later property cases stay independent)."""
  4433     try:
  4434         inbox.lifecycle.SettledIds(path)
  4435         return True
! 4436     except inbox.lifecycle.LedgerError:
! 4437         return False
  4438 
  4439 
  4440 def real_ids(path):
! 4441     return [line for line in Path(path).read_text(errors="replace").splitlines()
  4442             if inbox.lifecycle.local_task_protocol.valid_task_id(line)]
  4443 
  4444 
  4445 class Detector(unittest.TestCase):

src/task_lifecycle.py

Lines 35-44

  35 import errno
  36 import hashlib
  37 try:
  38     import fcntl
! 39 except ImportError:                                     # no flock: one writer per ledger, as before
! 40     fcntl = None
  41 import importlib
  42 import json
  43 import os
  44 import re

Lines 597-606

  597         if read and synced:
  598             try:
  599                 fsync_dir(os.path.dirname(self.path))
  600                 self.durable = read                     # never what a repair re-read later: no fsync of ours covered it
! 601             except OSError:
! 602                 pass                                    # visible, not yet durable: add() syncs before it counts
  603 
  604     def _sync_file_and_dir(self):
  605         _fsync_file(self.path)
  606         fsync_dir(os.path.dirname(self.path))

Lines 888-898

  888 
  889     def mark_calling(self, task_id, attempt):
  890         try:
  891             rec = self.claims.load(task_id)
! 892         except StateIOError:
! 893             self.open_attempts.discard(task_id)         # CALLING was never written: the attempt may be prepared again
! 894             raise
  895         self._save(rec, phase=CALLING, attempt=attempt)
  896 
  897     def _outcome_claim(self, task_id):
  898         """The claim an outcome is recorded on, after the provider was called: a read that fails here is part of

Lines 898-909

  898         """The claim an outcome is recorded on, after the provider was called: a read that fails here is part of
  899         that durable write (StateWriteError), so a serving loop stops and recovery answers the call at restart."""
  900         try:
  901             return self.claims.load(task_id)
! 902         except StateWriteError:
! 903             raise
! 904         except StateIOError as e:
! 905             raise StateWriteError(e.message[len("refusing to serve: "):]) from e
  906 
  907     def authorize_call(self, task_id, attempt, *, text_hash="", expected_phase=PREPARED):
  908         """One PREPARED -> CALLING transition per attempt: the one-use submit token (a random nonce
  909         stored on the claim with `text_hash`), or None when the claim is not `expected_phase` at this

Lines 1099-1108

  1099             return False
  1100         rec = self.claims.load(task_id)
  1101         if rec and rec["phase"] == SETTLED:
  1102             return self.reconcile_settled(task_id)
! 1103         self.settled.add(task_id)                       # a compacted claim: its id synced before it counts
! 1104         return True
  1105 
  1106     def reconcile_unbound(self, now=None):
  1107         """The SETTLED claims no generation owns (answered without a call): each settlement completed, and
  1108         compacted once due, as generation recovery does for its own. Under claims.lock, by the caller."""

Lines 1155-1166

  1155         the claim save alone: the blob is final and is never written again. finished | parked (no such
  1156         claim, or no verifying blob). A save that fails raises, as every claim save does."""
  1157         rec = self.claims.load(task_id)
  1158         if rec is None or rec.get("claim_mode") != LOCAL or rec["phase"] != TURN_CONFIRMED:
! 1159             return "parked"
  1160         descriptor = rec.get("staged")
  1161         if descriptor is None or self._verified(task_id, descriptor) is None:
! 1162             return "parked"
  1163         # The blob shares claim.json's directory, so this save's directory fsync makes its link durable too.
  1164         self._save(rec, phase=RESULT_STAGED)
  1165         self.open_attempts.discard(task_id)
  1166         return "finished"

Lines 1196-1204

  1196                     published = self._publish(rec)
  1197             except OSError as e:
  1198                 self.log("publication parked ->", task_id, e)
  1199                 if io_raises and isinstance(e, RenamedNotDurable):
! 1200                     raise PublishedNotDurable(str(e))
  1201                 if io_raises:
  1202                     raise _state_io("publish", self.results / ("%s.txt" % task_id), e)
  1203                 return "parked"
  1204             if published is None:

Lines 1255-1263

  1255             fsync_dir(self.results)
  1256             seen = self.read_final(final)
  1257             state = LIVE_READY if seen is not None else self.classify(task_id)
  1258         except OSError as e:
! 1259             raise RenamedNotDurable("%s landed, then %s" % (final, e))
  1260         if seen is not None and seen != body:
  1261             self.log("the published result differs from the stage ->", task_id)
  1262         return {"won": True, "state": state, "at": self.clock()}

Lines 1381-1389

  1381                                                                       "at": now})
  1382                 rec = self._save(rec, phase=RESULT_STAGED)
  1383             elif phase == TURN_CONFIRMED:
  1384                 if self.close_turn(task_id, "turn confirmed, no result", now) == "parked":
! 1385                     continue
  1386             elif phase in (OUTCOME_UNKNOWN, RESULT_STAGED):
  1387                 if phase != RESULT_STAGED:
  1388                     self.resume_outcome(task_id)
  1389             elif phase != PUBLISHED:

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at c2511c5.

What I ran (clean clone, no engine checkout touched):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 218 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 254 OK
  • tests/ag2-sparrow-task-lifecycle.test.py: OK
  • packages/ag2-sparrow/tools/test_no_drift.py: PASS (src/ and package copies byte-identical, also identical on main before this PR)
  • tests/ag2-sparrow-claude-local-bridge.test.py, tests/sparrowd-agent-bridges.test.py: OK
  • scripts/review-checks.sh --diff: PASS (no hardcoded paths in added lines)
  • Not run: the Mod harness (claude plugin test), and no live round trip.

On "both copies": the two task_lifecycle.py files are the pre-existing vendored pair guarded by the drift test, so interrupt_turn is not new duplicated policy. Lifting the recovery block into one method is an improvement. The new tests exercise behaviour through the CLI verbs and the Lifecycle API, not source text. Items 3, 14, 15 and 16 look correct to me.

Blocking (one ask):

  1. The TURN_CONFIRMED decision is still split, and the two callers disagree on a corrupt committed blob.
    • src/task_lifecycle.py:1208-1216 (_recover_generation): blob present and verifying -> RESULT_STAGED; blob present but not verifying -> "a committed result does not match its descriptor; parked", left untouched; no blob -> interrupt_turn.
    • inbox.py:310-313 (lost_turn_answered): anything other than finish_staged == "staged" -> interrupt_turn. That includes a blob that is present but does not verify.
      Probe: confirm a turn, stage "the accepted answer", roll the phase back to TURN_CONFIRMED (as your task-lost-move case does), then overwrite the committed blob so _verified() returns None, and claim again. The claim answers "its accepted answer could not be finished; answered as interrupted now". The result file gets the interrupted body. The claim settles with detail "turn confirmed, the accepted answer's staging was lost", and staged=None drops the descriptor. The same state reached through the next generation's recovery is parked with the descriptor kept. So the outcome for one claim depends on which path reaches it first, and the recorded detail says "lost" when the bytes were present but wrong.
      Either policy is defensible (interrupting is arguably kinder than parking forever). What needs fixing is that there are two. Please put the whole decision -- verifying blob -> stage, mismatched committed blob -> X, no blob -> interrupt -- in one Lifecycle method that both _recover_generation and lost_turn_answered call, and pin the mismatched-blob case for both callers. The PR body's "the move _recover_generation already made, now one method both call" holds for the move but not for the decision that leads to it.

Non-blocking:

  1. inbox.py:444 -- park's RESULT_STAGED branch returns parked=False even when the staged body is the protocol failure. Item 16 makes this newly reachable: park stages the failure, the publish raises StateIOError -> retry, and the identical park then hits this branch. register.ts:175 then logs "the accepted answer" for what was the protocol failure. The SETTLED branch at :441 already compares sha256 against the failure body; the same comparison here would make the two consistent. It only affects a log line.

  2. cli.py:133 with :541 -- for a config from before BRIDGE_STATE_DIR, verify's hitl row derives want from HITL_STORE itself (store_root(HITL_STORE.parent.parent)). So any /hitl/requirements-claude-local passes. Before this PR, an external-mode config was checked against hitl_store_root(). The bridge row still catches a store with no bridge beside it, so this is a weaker check rather than a hole. Worth a line in the README's old-config note, or a comparison against the derived default when the key is absent.

Live path: items 12, 14 and 16 change CLI/Mod delivery, so REVIEW.md #15 applies. A post-restart round trip is still owed, as the PR body says.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES. I agree with @liususan091219's blocker: the TURN_CONFIRMED decision is split between _recover_generation and lost_turn_answered. I'm adding a second repro that makes it a lost-answer case, not only a corrupt-blob one.

Blocking: a transient I/O error discards a verified accepted answer.

  • stage_result's except OSError: return "parked" also covers _commit_blob's temp write, which runs even when the final blob already exists and verifies.
  • lost_turn_answered (inbox.py ~309-313) treats any result from finish_staged other than "staged" as "no verifying blob", and calls interrupt_turn (staged=None).
  • Repro: stage "the accepted answer", roll the phase back to TURN_CONFIRMED (your task-lost-move setup), then claim with Lifecycle._commit_blob raising EIO once:
    • claim → 1 {'refused': 'task-probe: its accepted answer could not be finished; answered as interrupted now; ...'}
    • results/task-probe.txt → '[AG2 Space] This attempt was interrupted before the session answered; ...'
  • From the same state, lc.recover(generation=..., proof=False) publishes 'the accepted answer\n'. At base, the claim was refused and recovery would have delivered the answer, so this window is a regression.
  • Ask: in the single Lifecycle method for the TURN_CONFIRMED decision (as requested in the other review), make an I/O failure while finishing a verifying blob a retry (StateIOError/Parked), never an interrupt. Pin it with a transient-failure case.

Non-blocking:

  • publish answers "the step did not commit (...); retried" when fsync_dir(results) fails after move_private, even though results/.txt already holds the answer. The retry converges with no duplicate (settled; one result file), so this is wording only.

Verified at this head:

  • test_claude_code_adapter.py: 218 OK.
  • test_task_lifecycle.py: 254 OK; tests/ag2-sparrow-task-lifecycle.test.py: 254 OK.
  • test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS. The two task_lifecycle.py copies are byte-identical; they are the existing vendored pair, not new duplicated policy.
  • claude-local-bridge: 19 OK; sparrowd-agent-bridges: 10 OK.
  • review-checks --diff: PASS. gh pr checks: all pass or skip.
  • Mutants: disabling the finish_staged branch (item 12), the SETTLED short-circuit in park (item 14), and io_raises in publish (item 16) each fail their pinning test.

Not verified:

  • The Mod harness (claude plugin test; 67/67 per the PR body).
  • Any live or post-restart round trip. REVIEW.md #15 applies to items 12, 14 and 16.
  • Item 3 on a real external-bridge install.
  • systemd/Linux.

Reviewed at c2511c5.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head c2511c5d1609b547befe531c6861a4f2e7f3264b.

Blocker — an interruption can commit OUTCOME_UNKNOWN and then be retired forever

Lifecycle.interrupt_turn() makes the interrupted body durable, saves the claim as OUTCOME_UNKNOWN, and only then saves RESULT_STAGED (packages/ag2-sparrow/ag2_sparrow/task_lifecycle.py:1014-1016). If the first claim.json rename lands but its directory fsync fails, ClaimStore.save() correctly reports StateCommitted (:658-667) and _claims_step() returns a hard committed reply (inbox.py:186-206). The durable record is now OUTCOME_UNKNOWN.

On the next same-generation claim, however, lost_turn_answered() handles only TURN_CONFIRMED, RESULT_STAGED, and PUBLISHED (inbox.py:299-320). It therefore returns the final refusal attempt 1 is OUTCOME_UNKNOWN. The Mod treats any non-retry refusal as final and inserts the task file into seen (hooks/register.ts:331-339). No result exists, and the task is not reconsidered until the generation dies/restarts. That contradicts this PR's “lost turn answered at once” contract.

I reproduced this against the real writer by injecting EIO at the directory fsync after the OUTCOME_UNKNOWN rename:

first:  hard=true, phase=OUTCOME_UNKNOWN, "claim.json was replaced, then its directory fsync failed"
second: retry absent, refused="task-outcome-unknown-real: attempt 1 is OUTCOME_UNKNOWN"
result_exists=false

Please make the interruption land in a recoverable/publishable phase in one durable transition, or make the shared reclaim/recovery policy explicitly resume every committed intermediate state, including OUTCOME_UNKNOWN. Pin the post-rename-fsync failure through the live claim/Mod path so a result is eventually published without waiting for holder death.

Other exact-head blockers confirmed

I independently reproduced both existing requested-change findings on this same SHA:

  • a transient blob-read/write I/O failure during same-generation reclaim can replace a valid accepted answer with the interruption body, while dead-generation recovery later publishes that accepted answer (review evidence);
  • a corrupt committed blob is handled inconsistently by live reclaim versus dead-generation recovery, so the full TURN_CONFIRMED decision is still split across adapters (review evidence).

The worst case is therefore either loss/replacement of an accepted answer or a silently stranded owner task. The fix should centralize the complete decision, preserve accepted descriptors/bytes on storage I/O, and give live reclaim and recovery identical outcomes.

Verification and merge readiness

  • Exact-head suites passed: adapter 218, both lifecycle copies 254 each, bridge integration 19, sparrowd bridges 10, adapter boundaries 8.
  • Drift checks, git diff --check, and scripts/review-checks.sh --diff passed; CI and CLA are green.
  • Selected new tests fail on parent 7f7f56a8 and pass on this head.
  • A separate independent QA pass reproduced the two existing blockers and found no conflicting result.
  • The PR changes a live bridge/delivery path, but it explicitly reports no post-restart round trip. The required REVIEW.md §15 witness is still missing.

This head is not merge-ready until the three lifecycle blockers are fixed and a real post-restart witness is attached.

— Sutando (Neo seat)

sonichi and others added 2 commits October 20, 2026 10:04
…ery and the lost claim; I/O while finishing a verifying blob always retries

Review round 1 (Susan, Qingyun): the decision for a TURN_CONFIRMED claim nobody will stage again
lived in two places that disagreed. Lifecycle.close_turn now takes it for both _recover_generation
and inbox.lost_turn_answered: a descriptor whose blob verifies is finished (finish_staged) and
published; a blob missing or not matching its descriptor is answered as interrupted, the detail
naming the mismatch, never those bytes; an I/O failure while finishing a verifying blob is parked
(the claim as it was, the verified body published on the next call), never an interrupt. A
mismatched committed blob in recovery was parked with the descriptor kept; it is now interrupted
through the same method (the suite's pin updated).

Also from the round: park names the protocol failure when that is what the recorded descriptor
delivers, in every branch; verify prints an INFO line for a config from before BRIDGE_STATE_DIR
instead of checking the HITL store against a value derived from itself; a publication whose rename
landed but whose directory fsync or read-back failed answers retry as "the result landed, its
durability unknown" (PublishedNotDurable), never "did not commit".

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…itted OUTCOME_UNKNOWN is resumed and published, never retired

Lifecycle.interrupt_turn() saved the claim at OUTCOME_UNKNOWN and then at RESULT_STAGED. When the
first claim.json rename landed and its directory fsync failed, ClaimStore.save() raised
StateCommitted, the live claim answered hard, and OUTCOME_UNKNOWN stayed durable; the next
same-generation claim's lost_turn_answered() handled only TURN_CONFIRMED, RESULT_STAGED and
PUBLISHED, so it gave the final refusal "attempt 1 is OUTCOME_UNKNOWN" and the Mod retired the task
with no result until the generation died.

- interrupt_turn() now lands in RESULT_STAGED in one save (the body is durable before it), so a rename
  that lands always leaves a publishable claim.
- Lifecycle.resume_outcome() is the one move for a local claim committed at OUTCOME_UNKNOWN (left by
  an older write, or by recovery's own two-step CALLING path): generation recovery calls it, and the
  live reclaim (lost_turn_answered) resumes and publishes the durable interrupted body instead of
  refusing. Both lifecycle copies changed together (drift test).
- The 9ff7152 rules are unchanged: a verifying blob is never interrupted; I/O while finishing is a
  retry.

Pins: the live claim path with EIO at the directory fsync after the claim.json rename (first claim
hard at RESULT_STAGED, the next claim publishes the interrupted body; at c2511c5 and 9ff7152 the
first claim leaves OUTCOME_UNKNOWN and the second refuses "attempt 1 is OUTCOME_UNKNOWN" with no
result), a committed OUTCOME_UNKNOWN resumed by the next claim and by recovery, and the Mod harness:
a hard lost-turn claim keeps the task, intake reopens, the next claim publishes once, nothing
submitted.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at e059763 (incremental from c2511c5 via 9ff7152).

What I ran (clean clone):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 219 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 262 OK; tests/ag2-sparrow-task-lifecycle.test.py: OK
  • tools/test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS (both task_lifecycle.py copies byte-identical)
  • test_adapter_boundaries, ag2-sparrow-claude-local-bridge, sparrowd-agent-bridges: OK
  • claude plugin test ag2space-inbox: 68 pass, 0 fail; claude plugin validate: passed with warnings
  • Probes through the real CLI verbs and Lifecycle.recover, below. No live round trip.

Resolved from my review at c2511c5:

  • #1: close_turn is now the one TURN_CONFIRMED decision. I re-ran the mismatched-blob probe through both callers. Live claim -> SETTLED, interrupted, detail "... (the committed result does not match its descriptor)". Generation recovery -> the same. Both are pinned (test_close_turn_is_the_one_decision..., test_generation_recovery_answers_a_mismatched_committed_blob...).
  • #2: park compares the staged sha in the TURN_CONFIRMED/RESULT_STAGED/SETTLED branch, so a retried protocol failure is no longer logged as the accepted answer.
  • #3: the hitl row for a config without BRIDGE_STATE_DIR is an INFO line, not a self-derived PASS.

Also checked:

  • interrupt_turn: claims.stage (write_durable) runs before the single RESULT_STAGED save. A crash between them leaves TURN_CONFIRMED with the body on disk, which is safe to redo.
  • resume_outcome: both writers of OUTCOME_UNKNOWN (task_lifecycle.py:1171 and :1243) stage the body first, so moving it to RESULT_STAGED is sound.
  • The PublishedNotDurable -> Parked retry: the Mod backs it off and marks it STUCK after STUCK_TICKS (register.ts:233-245), so a persistent failure is surfaced, not a silent loop.
  • The live transient-I/O case (Qingyun's) is fixed: claim with one EIO in _commit_blob -> retry; next claim -> "the accepted answer" published.

Blocking:

  1. Restart recovery now strands a verified accepted answer on one transient I/O error (a regression vs main).
    close_turn (src/task_lifecycle.py:1046) finishes a verifying blob through finish_staged -> stage_result, which runs _commit_blob's temp write again. At c2511c5 (and main), _recover_generation moved a verifying claim to RESULT_STAGED with a claim save and no blob write.
    Probe: stage "the accepted answer", roll the phase back to TURN_CONFIRMED, then call lc.recover(generation=, proof=False) with _commit_blob raising EIO once.
    • e059763: commit_blob called once -> phase TURN_CONFIRMED, no result.
    • c2511c5: commit_blob never called -> SETTLED, "the accepted answer\n" published.
      Recovery runs once per hold start (hold.py:159 -> inbox.recover_dead_generation). In between, a Mod claim on that task is another generation's TURN_CONFIRMED claim and gets the final refusal "attempt 1 is TURN_CONFIRMED" (no retry), so the Mod retires it. The answer is not delivered until the next hold start, and the only trace is the "staging parked" log line.
      test_generation_recovery_retries_a_verifying_blob_whose_finish_failed_on_io_never_interrupting calls recover twice back to back, which production never does. The second pass it relies on does not exist within a generation.
      Ask: when _verified() passes, close_turn should move the claim to RESULT_STAGED without re-committing the blob, as main did. The blob is already final and verified, so there is no I/O to fail. Alternatively, make a parked close_turn in recovery visible and retried within the generation. Pin it through the real recovery cadence: one recover pass, then the live claim.

Non-blocking:

  1. Lifecycle.recover's docstring (src/task_lifecycle.py:1133) still says "with a mismatched blob parks". It now interrupts.

Verdict: REQUEST_CHANGES on 1. Items 12, 14 and 16 change the CLI/Mod delivery path, so REVIEW.md #15 still applies and a post-restart round trip is owed before merge.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT: the code is clear; the §15 witness is still pending.

I re-reviewed at e059763 in a read-only clone and pushed nothing.

Prior blockers. I also ran each repro at c2511c5 and 9ff7152.

  1. My blocker (5588548239): a single EIO in _commit_blob with a verifying blob. FIXED.
    • claim#1 → {'refused': '…the accepted answer could not be closed now; retried', 'retry': True}, result None, phase TURN_CONFIRMED.
    • claim#2 publishes 'the accepted answer\n', and the phase is SETTLED. This matches lc.recover. (c2511c5 published the interrupted line.)
    • The "did not commit" nit is fixed for the publish verb: it now says "the result landed, its durability unknown (…); retried", and the retry settles.
  2. @liususan091219's blocker (5588475159): the split TURN_CONFIRMED decision. FIXED.
    • close_turn is now the one decision both callers use.
    • Corrupt blob: the claim ends SETTLED + interrupted, and recovery ends SETTLED + interrupted with the same detail suffix. (At c2511c5, recovery stayed TURN_CONFIRMED and parked.)
  3. @keweichen's blocker (5588690901): his repro, an EIO at the directory fsync after the claim.json rename. FIXED.
    • The first claim gets rc=3 hard, with phase RESULT_STAGED.
    • The second claim publishes the interrupted body, and the phase is SETTLED.
    • (At c2511c5 and 9ff7152 it stayed OUTCOME_UNKNOWN, then "attempt 1 is OUTCOME_UNKNOWN", with no result.)

resume_outcome.

  • The only writers of OUTCOME_UNKNOWN are record_outcome (cloud-only) and the CALLING branches of recover and _recover_generation. All three stage the body through write_durable before saving the claim.
  • A rename whose directory fsync fails raises StateCommitted before the save, and unstage runs only in _settle. So nothing that wasn't durably staged can reach RESULT_STAGED.
  • It returns "parked" for SETTLED, PUBLISHED, RESULT_STAGED and TURN_CONFIRMED.
  • Forcing a published claim back to OUTCOME_UNKNOWN and then claiming gives "is LIVE_READY", with one result file holding the original answer. There is no republish.

interrupt_turn: write_durable fsyncs the file, renames it, fsyncs the directory, then makes the one claim save.

Ran at e059763:

  • adapter: 219 OK
  • lifecycle: 262 OK in the package copy and 262 OK in the src copy; the two copies are byte-identical
  • drift checks: PASS
  • claude-local-bridge 19 OK; sparrowd bridges 10 OK; boundaries 8 OK
  • git diff --check clean; review-checks.sh --diff PASS
  • Mod harness (claude-code@2.1.303): 68 pass, 0 fail
  • gh pr checks: all green, CLA passes

Mutations (package copy), each caught:

  • resume_outcome never moving the claim
  • a close_turn finish I/O failure that interrupts instead of retrying
  • interrupt_turn going back to two saves

Non-blocking:

  • A. lost_turn_answered doesn't catch PublishedNotDurable. So when a lost claim's publish rename lands and its fsync then fails, the claim answers "the step did not commit (PublishedNotDurable: … landed …)". The answer is delivered, nothing is published twice, and the next claim refuses with "is LIVE_READY". But the claim stays RESULT_STAGED until the next generation's recovery. The same except clause that publish() already has would fix this.
  • B. The recover docstring (task_lifecycle.py:1133, both copies) still says "with a mismatched blob parks"; it now answers as interrupted.

This changes the live CLI/Mod delivery path, so REVIEW.md §15 applies. No post-restart round trip is attached, so this is a COMMENT. My c2511c5 REQUEST_CHANGES stays in place until the §15 witness is posted, and then I'll replace it.

Reviewed at e059763.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A follow-up to my COMMENT at e059763. There's no contradiction with @liususan091219's new blocker: we tested different paths.

  • My EIO repro went through the live claim path, which retries and then publishes. My recovery check ran lc.recover with no injected error.
  • Susan's blocker is about generation recovery with the EIO injected. close_turn re-commits a blob that has already verified, and recovery runs only once per hold start. So one transient error parks the claim. The next live claim then gets the final refusal "attempt 1 is TURN_CONFIRMED", and the answer waits for the next hold start.
  • I didn't test that path, so my "code-clear" doesn't cover it. I agree with her blocker. The cleanest fix is hers: when _verified() passes, move the claim to RESULT_STAGED without writing the blob again. Pin it with one recover pass followed by the live claim.

Reviewed at e059763.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head e05976369520e06e38cf525be6b959d2a5954eaa against both pinned baselines (c2511c5d, 9ff71523) and the current production call graph.

My prior blocker is fixed. Replaying the post-claim.json-rename directory-fsync failure leaves OUTCOME_UNKNOWN at both old heads; at this head it leaves RESULT_STAGED, and the next same-generation claim publishes and settles. interrupt_turn() now stages the body before one claim save, resume_outcome() is limited to local OUTCOME_UNKNOWN, and both the live reclaim and dead-generation recovery use it. The canonical and vendored lifecycle copies are byte-identical.

Blocking:

  1. P2 — a post-publication retry leaves a delivered task persistently mirrored as in-flight and raises a false stall. I faulted read_final() after the result rename landed while lost_turn_answered() was publishing. The first claim correctly returned a retry, but the durable state was already:

    phase=RESULT_STAGED
    result exists
    progress.inflight={phase: TURN_CONFIRMED, turn_id: turn-1}
    

    The next claim returned ... is LIVE_READY; only ABSENT may be submitted. State was still RESULT_STAGED, the TURN_CONFIRMED mirror remained, and the production detector then reported static for 601 s with task-lost-publish-rename in flight.

    Cause: packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/inbox.py:253-279 takes the terminal-result branch before lost_turn_answered() and clears only stuck; it neither reconciles the lifecycle through publish_and_settle() nor removes the inflight mirror. register.ts then marks the final-refused task seen. progress() preserves the mirror, and dead-generation recovery does not clear it, so there is no automatic cleanup path.

    Please reconcile a terminal result with a RESULT_STAGED/PUBLISHED claim and clear the mirror, or provide an equivalent single-owner transition. Pin the actual sequence through claim: result rename lands, read-back/fsync path fails, next claim settles, progress.inflight is gone, and no stall card is emitted.

  2. P1 — one transient I/O error during the single startup recovery pass can strand an accepted dead-generation answer for the lifetime of the new session. I independently reproduced Susan's current-head blocker. With a verified committed blob and one _commit_blob EIO:

    recovered_count 1
    after_recovery {'finish_failures': 1, 'phase': 'TURN_CONFIRMED', 'result_exists': False}
    new_generation_claim (1, {'refused': 'task-dead-recovery-one-pass: attempt 1 is TURN_CONFIRMED'})
    after_claim {'phase': 'TURN_CONFIRMED', 'result_exists': False}
    

    _recover_generation() treats close_turn() == "parked" as continue; production runs one recovery pass and then starts the new generation. That generation cannot recover the old claim and final-refuses it, so the Mod retires the task. The added unit test's immediate second recover() call is not present in production. Please either advance an already-final-and-verified blob without recommitting it, as main did, or retry/surface the parked recovery before admitting the new generation. Pin one production recovery pass followed by the live claim.

Merge gate: this changes the CLI/Mod delivery and startup recovery paths, but the PR still supplies no real post-restart provider → task → result witness required by REVIEW.md §15.

Evidence at this head: adapter 219/219; lifecycle 262/262 in each copy; claude-local bridge 19/19; sparrowd bridges 10/10; adapter boundaries 8/8; drift checks, git diff --check, and review checks pass. All current GitHub checks and license/cla are green. An independent QA replay confirmed blocker 1. No source changes made.

Verdict: REQUEST_CHANGES until both recovery paths converge in the live cadence and the §15 witness is attached.

…the live claim delivers what one recovery pass left; a found result settles the claim and clears in-flight

Recovery (review round 3, the recovery path): close_turn finished a verifying blob through
finish_staged, which re-committed the blob, so one transient EIO in the single recovery pass a hold
start runs left the claim TURN_CONFIRMED with no result, and the new generation's live claim gave the
final refusal "attempt 1 is TURN_CONFIRMED". Lifecycle.finish_verified now moves such a claim to
RESULT_STAGED with the claim save alone (the blob is final; that save's directory fsync covers its
link, same directory), and close_turn uses it (both lifecycle copies, drift test green). The live
claim also delivers what one pass left publishable for another generation: a verifying blob, a
staged answer whose publication failed, a committed OUTCOME_UNKNOWN; an interruption stays recovery's.

In-flight (review round 3, the publication read-back): a lost claim whose result rename landed and
whose read-back failed answered retry; the next claim took the terminal-result branch, cleared only
the stuck mark, and left the claim RESULT_STAGED with progress.inflight at TURN_CONFIRMED, so the
detector reported a false stall. A claim that finds the result now settles a local RESULT_STAGED or
PUBLISHED claim on it (the publisher adds none), and drops the in-flight mirror with the stuck mark
in one write. lost_turn_answered maps PublishedNotDurable to the publish verb's retry.

The e059763 OUTCOME_UNKNOWN fix and the 9ff7152 rules stand (a verifying blob is never
interrupted; I/O while finishing is a retry, now the claim save's). The recover docstring no longer
says a mismatched blob parks.

Pins: one recover_dead_generation pass, then live claims, no second pass (fails at e059763; the
blob-EIO part passes at c2511c5); one recovery pass in the lifecycle with the blob writer failing
(both copies); the read_final fault through claim (first claim retry, next claim settles, inflight
empty, no stall; fails at e059763 and c2511c5).

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 7e09b8d (incremental from e059763).

What I ran (clean clone):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 221 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 262 OK; tests/ag2-sparrow-task-lifecycle.test.py: 262 OK
  • tools/test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS (both task_lifecycle.py copies byte-identical)
  • test_adapter_boundaries 8 OK; ag2-sparrow-claude-local-bridge 19 OK; sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 68 pass, 0 fail; validate passed with warnings
  • scripts/review-checks.sh --diff: PASS
  • Probes through the real claim verb and Lifecycle.recover, below. No live round trip.

My blocker from e059763 is resolved:

  • close_turn (src/task_lifecycle.py:1061) now moves a verifying blob on through finish_verified (:1035), which is a claim save only. _verified() re-reads the blob and checks length and sha256 against the identity-bound descriptor, so a stale or mismatched blob cannot pass.
  • Probe: stage "the accepted answer", roll back to TURN_CONFIRMED, then ONE lc.recover pass with _commit_blob armed to raise EIO. Result: _commit_blob called 0 times; SETTLED; "the accepted answer\n" published.
  • A new-generation live claim on a dead generation's TURN_CONFIRMED claim with a verifying blob (inbox.py:329) -> "staged and never published, is published now". With a mismatched blob it returns "" and leaves the interruption to recovery, which close_turn now always performs, so nothing strands.
  • Mismatched blob, both callers: live claim -> SETTLED, interrupted, "... (the committed result does not match its descriptor)"; recovery -> the same. Still pinned for both.
  • The recover() docstring (:1148) now matches.
  • test_one_recovery_pass_then_the_live_claim_delivers_a_dead_generations_verified_answer_once uses the production cadence (one recover_dead_generation pass, then live claims).

Kewei's read-back case is converged:

  • Probe: RESULT_STAGED, read_final raising EIO after the rename. Claim #1 -> retry ("the result landed, its durability unknown"), result present, inflight still set. Claim #2 -> settles via _settle_terminal (inbox.py:233, :284), inflight cleared, stuck None.
  • _clear_stuck (:214) drops stuck and inflight in one write_progress. The settle happens first under claims.lock and the mirror clear second; a failed clear is a retry, and the next claim converges (settle is a no-op at SETTLED). Pinned by test_a_lost_claims_publication_whose_read_back_fails_after_the_rename_settles_and_clears_in_flight, which includes the 601 s no-stall check.
  • Qingyun's note A: lost_turn_answered now catches PublishedNotDurable (:352).

No remaining code findings from me.

Not yet ready to approve:

  • The REVIEW.md #15 post-restart round trip (provider -> task -> result) is still not attached. Items 12, 14 and 16 and startup recovery are on the delivery path.
  • At this head the commit status endpoint shows license/cla absent (pending, 0 contexts); it was success at e059763. It needs to re-run.

My earlier REQUEST_CHANGES is addressed in code; it stays on record until an approval replaces it.

— Sutando (susans-MacBook-Pro)

@sonichi

sonichi commented Oct 20, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT: the code is clear; the §15 witness is still pending.

I re-reviewed 7e09b8d in a read-only clone, ran every repro at e059763 as well, and pushed nothing.

@liususan091219's blocker (one EIO in the single recovery pass strands the answer): FIXED.

  • Her exact repro: one lc.recover(generation=G, proof=False) with _commit_blob raising EIO once, then a new generation's claim.
    • 7e09b8d: commit_blob calls=0, SETTLED, 'the accepted answer\n' published; the claim gets is LIVE_READY.
    • e059763: commit_blob calls=1, TURN_CONFIRMED with no result; the claim gets attempt 1 is TURN_CONFIRMED.
  • Through hold's recover_dead_generation: the same at both heads.
  • A corrupt blob is still interrupted on both paths and is never published.
  • My earlier EIO-on-claim repro now publishes on the first claim.

@keweichen's blocker (a delivered task left in flight): FIXED.

  • 7e09b8d: claim#2 gets LIVE_READY; SETTLED with won=True; the in-flight mirror is gone; stall at 601 s: None.
  • e059763: RESULT_STAGED, the mirror is kept, and the claim is reported static at 601 s.
  • With a different body already at results/<t>.txt, the claim settles with won=False and leaves that result alone. That is the publisher's existing never-replace rule.

My earlier items:

  • A: fixed, but not pinned. "the result landed, its durability unknown … retried", and the next claim settles. Removing the new except PublishedNotDurable survives both suites; asserting the message would catch it.
  • B: fixed in both copies.

New, non-blocking:

  1. Recovery never clears a dead generation's in-flight mirror. With no later claim on that task, the detector reports static for 601 s. This reproduces the same at the merge-base, so it predates the PR. Worth a follow-up issue.
  2. A RESULT_STAGED claim whose blob is corrupt, on a task that already has a result, now gets "did not settle now … retried" on every claim, because _publish checks the blob before it looks at the existing result. The stuck mark is filtered out as resolved, so nothing surfaces it. It was a final refusal at e059763. It's rare, and settling on the existing result first would close it.
  3. finish_verified refusing a blob that doesn't verify is pinned only through the adapter. A lifecycle-level pin would be cheap.

Ran at 7e09b8d:

  • adapter 221 OK; lifecycle 262 OK; the other lifecycle, bridge and boundary suites OK
  • the two copies are byte-identical; drift checks PASS
  • Mod harness (claude-code@2.1.303): 68 pass, 0 fail
  • review-checks.sh --diff PASS
  • every new pin fails at e059763
  • six of seven mutations caught (the exception is A's except, above)
  • gh pr checks: 35 pass, 3 skipping

license/cla is absent at this head. The @cla-assistant check comments didn't restore it; close and reopen the PR to retry.

This is a live delivery and startup-recovery path, so REVIEW.md §15 applies. My e059763 REQUEST_CHANGES stays in place until the §15 witness is posted and license/cla is green.

Reviewed at 7e09b8d.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate restated, no new findings. My latest COMMENT at this head says the code is clear. A COMMENT review replaces my earlier REQUEST_CHANGES in latestReviews, so I am re-stating the gate explicitly. The only remaining items are license/cla green and the REVIEW.md §15 post-restart witness (CLI/Mod delivery + startup recovery). I will replace this with an APPROVE once those land.

At 7e09b8d

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 7e09b8d647e96d863397a40f063dab594dce8898 (incremental from e0597636). REQUEST_CHANGES: both reported recovery defects are fixed, but the newly activated terminal-settlement path has one durability hole, and the required live witness is not attached.

The two round-3 code blockers now discriminate correctly:

  • One production recovery pass with publication failing leaves the verified answer RESULT_STAGED; the next live claim publishes the exact accepted body and reaches SETTLED. The same probe at e0597636 remains TURN_CONFIRMED with no result and the live claim refuses it.
  • After the result rename lands and read_final raises, the next claim settles on the existing result, preserves its inode/bytes, and removes inflight plus stuck in one production write_progress mutation. The detector reports no stall at 601 s. At e0597636, the claim remains RESULT_STAGED, inflight remains, and the detector reports the false 601 s stall.

Blocking durability case:

  1. Lifecycle._settle() saves phase=SETTLED before SettledIds.add() (src/task_lifecycle.py:993-996, vendored copy identical). If that append fails during _settle_terminal(), the claim correctly returns a retry, but the durable state is already SETTLED with no settled-ID record. The next claim skips _settle_terminal() because it accepts only RESULT_STAGED/PUBLISHED (inbox.py:233-240), clears the progress mirror, and final-refuses. recover_dead_generation() also excludes SETTLED claims (inbox.py:95-97), so a later holder recovery does not repair it.

    Exact fault probe:

    claim #1: retry; phase=SETTLED; settled-id absent
    claim #2: LIVE_READY final refusal; phase=SETTLED; settled-id still absent
    dead-generation recovery: 0 generations; settled-id still absent
    

    The result is preserved and I observed no duplicate delivery, but settlement remains permanently incomplete and the claim cannot compact. Please make a retry of an already-SETTLED local claim reconcile the settled-ID ledger before progress cleanup/final refusal, in the shared lifecycle owner, and pin this exact SettledIds.add failure sequence through the live claim path.

    While extending that pin, seed both stuck and inflight (plus an unrelated neighbor) and assert the single production mutation. The new test's docstring promises both-map cleanup, but its setup/assertions currently cover only inflight; an independent production-writer probe confirms the implementation does clear both.

Validation at this SHA: adapter 221/221; both lifecycle copies 262/262; Mod hook suite 68/68; claude-local bridge 19/19; sparrowd bridges 10/10; adapter boundaries 8/8; both drift guards, git diff --check, and review-checks.sh pass. The hardcoded-path scanner positive control fails as expected. Independent QA reproduced both fixes against the parent and the ledger failure above.

license/cla is now green. A replacement CI run was still finishing when I submitted this review (all completed jobs green; two jobs pending).

The REVIEW.md §15 gate also remains open: this changes startup and CLI/Mod delivery, but no real post-restart provider → task → result transcript/output is attached. The PR body still says no such run was performed from the clone. A witness said to exist in an owner's combined session is not reviewable evidence until its actual command/output is posted.

Worst-case disruption from the remaining code defect is durable state debt after one transient ledger-write failure; the shipped task result survives, but the local settlement invariant never converges. Merge readiness: changes requested until the ledger retry is repaired and pinned, the §15 witness is attached, and the current required checks finish green.

…leted before any cleanup, and recovery compacts it

Lifecycle._settle() saves SETTLED, then appends the id to settled-ids and removes the staged body.
An append that faulted left the claim SETTLED with no ledger entry: that claim answered retry, the
next claim's terminal branch handled only RESULT_STAGED/PUBLISHED, cleared the progress mirror and
final-refused, and recover_dead_generation skipped every SETTLED claim, so the settlement never
completed and the claim never compacted (the result survived; nothing was published twice).

- Lifecycle.reconcile_settled() completes a SETTLED claim's settlement: the id appended if missing
  (SettledIds.add is idempotent), the staged body removed. _settle ends with it, and generation
  recovery's re-record pass uses it (both lifecycle copies, drift test green).
- The claim verb's terminal branch reconciles a SETTLED local claim under claims.lock, before the
  progress cleanup and the final refusal; a ledger I/O failure stays the pre-commit retry.
- recover_dead_generation also brings in the generation of a SETTLED claim still owed its ledger
  entry or due for compaction, so a settled claim compacts once its time comes.

Pins: the settled-ids append faults once, then a second claim: ledger entry present, staged body
gone, then one recovery pass past COMPACT_AFTER compacts the claim (fails at 7e09b8d: no ledger
entry after the second claim); reconcile_settled in the lifecycle (both copies). The in-flight
cleanup test now seeds and asserts both maps (inflight and stuck) with an untouched neighbor, in
one production write.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 20928ff (incremental from 7e09b8d).

What I ran (clean clone):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 222 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 264 OK; tests/ag2-sparrow-task-lifecycle.test.py: 264 OK
  • tools/test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS (both task_lifecycle.py copies byte-identical)
  • test_adapter_boundaries 8 OK; ag2-sparrow-claude-local-bridge 19 OK; sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 68 pass, 0 fail; validate passed with warnings
  • scripts/review-checks.sh --diff: PASS
  • Probes through the real verbs, Lifecycle and recover_dead_generation, below. No live round trip.

Kewei's ledger case (src/task_lifecycle.py:993-1008, inbox.py:236, :97):

  • Append faults once (SettledIds.add raises on its first call): publish -> retry, claim SETTLED, no ledger entry. The next claim -> reconcile_settled under claims.lock, then the mirror clear, then LIVE_READY. End state: id in the ledger exactly once, staged body gone, one result write (move_private called once), inflight cleared.
  • Crash between the append and the body removal (unstage raises): SETTLED, id in the ledger, result intact. reconcile_settled called twice afterwards -> True/True, one ledger line, body gone. Idempotent.
  • Scope of the removal: reconcile_settled on a RESULT_STAGED claim -> False, legacy body kept, nothing appended. On a missing claim -> False. Settling one task removes only its own body; a neighbour RESULT_STAGED claim keeps its body and phase.
  • Recovery pickup: a dead generation's SETTLED claim missing its ledger entry -> recovered, entry written, 0 result writes, same inode and bytes. With settled_at past COMPACT_AFTER and the entry owed, the same pass writes the entry, then compacts. decide() then answers settle and a later claim refuses LIVE_READY. Nothing re-opened or re-published.
  • Lock order: the claim verb and recover_dead_generation both take claims.lock first, and the progress write in claim follows its release. 20 claims raced against 20 recovery passes in two threads completed in 0.23 s with no errors.
  • The new pins seed both stuck and inflight plus an untouched neighbour, and assert one write_progress call, as Kewei asked.

No regressions: my earlier probes rerun at this head (one recovery pass with the blob writer armed -> 0 writes and published; new-generation claim on a verifying blob -> published; mismatched blob through the live claim and recovery -> interrupted; read-back failure after the rename -> settles and clears in-flight).

No code findings from me at this head.

Still open before approval:

  • The REVIEW.md #15 post-restart round trip (provider -> task -> result, the actual output) is not attached. This is the startup-recovery and CLI/Mod delivery path.
  • license/cla is back: success at this head.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 20928ff5aafb7f6277325ab9f8a7c01aeb84d0ee. REQUEST_CHANGES: the prior live-claim ledger blocker is fixed, but the same settlement debt still escapes through adjacent production paths, the claimed idempotent append is not idempotent across waiting writers, and the required live witness is not attached.

The requested path now discriminates correctly: after one SettledIds.add fault, claim #1 retries with SETTLED/no ledger entry; claim #2 runs reconcile_settled() under claims.lock, records the ID, removes a real seeded legacy body, clears both progress maps in one write while preserving a neighbor, and later dead-generation recovery compacts without changing the one result's bytes/inode. The parent 7e09b8d6 leaves the ID/body unrepaired and recovers zero generations.

Blocking — already-SETTLED success/final paths do not all reconcile

publish_and_settle() returns "settled" for an already-SETTLED record without calling reconcile_settled() (src/task_lifecycle.py:1079-1103, vendored copy identical). inbox.publish() uses that result directly, and park() routes its SETTLED retries through publish() (packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/inbox.py:463-493). With a one-shot ledger-append fault:

publish #1: retry; phase=SETTLED; ledger=false; body=true
publish #2: rc=0, outcome=settled; ledger=false; body=true

The second call clears the mirror and tells the Mod settlement succeeded without completing either remaining step. A later proven-dead generation repairs this case, but a live generation can retain the debt indefinitely.

The permanent variant is complete_without_call(), whose claims have no claim_mode or generation (src/task_lifecycle.py:981-985). _settle_terminal() rejects them before its new SETTLED branch, while dead-generation recovery selects only generated local claims (inbox.py:84-105,236-250). The real non-owner claim path gives:

claim #1: retry after LedgerIOError; phase=SETTLED; ledger=false; body=true
claim #2: final LIVE_READY refusal; ledger=false; body=true
dead-generation recovery: 0; ledger=false; body=true

interrupt() is worse: after the same append fault, its catch plus resolved() turns the first call into rc=0, resolved=true, answered=false, state=SETTLED; repeated interrupt/claim and recovery still leave the ledger absent and body present (inbox.py:314-324,381-411). These are the same settlement invariant and retry decision this round changes, not unrelated cleanup. Please route every already-SETTLED path that reports success/finality through the shared reconciliation under the claims lock, and pin direct publish/park, non-admitted claim, and interrupt with the append failure.

Blocking — the append is only idempotent in one cached Lifecycle

Lifecycle loads SettledIds.ids before acquiring the adapter's claims lock (src/task_lifecycle.py:729-742; inbox.py:274-277,463-465). SettledIds.add() checks only that cached set before appending (src/task_lifecycle.py:604-627). I created two lifecycle instances while the ledger was empty, then serialized both reconcile_settled() calls through the production records.flocked(claims_lock_path(...)):

prelock_cached=false false
ledger_bytes='task-race\ntask-race\n'
record_count=2

So overlapping CLI retries can append the same ID twice despite the lock and the new “idempotent append” contract. Please make the production writer's idempotence cross-instance and pin two stale readers/waiting writers; repeated races otherwise permit unbounded duplicate ledger growth.

The new body-removal assertions also need a real body: local descriptor/blob staging never creates claims/<id>/body, so replacing ClaimStore.unstage() with a no-op leaves all three new pins green. Seed the legacy body as the failure-mode probe does. The implementation does remove it when reconciliation is actually reached.

Validation at this SHA: adapter 222/222; lifecycle 264/264 (source/vendored bindings); Mod 68/68; bridge 19/19; sparrowd 10/10; boundaries 8/8; both drift guards, git diff --check, and review-checks.sh pass, including a firing hardcoded-path positive control. Current CI and CLA are green.

REVIEW.md §15 remains independently blocking. This changes startup/recovery and CLI/Mod delivery, but the PR still says no post-restart round trip was run and no real provider → task → result command/output is attached. “Will be posted” is not reviewable evidence yet.

Worst-case disruption is a task reported settled/final while its durable settlement remains incomplete: plaintext staging can persist, generation-less claims never compact, and concurrent retries can grow duplicate ledger records. Merge readiness: changes requested until these paths converge through the one writer contract, the failure modes are pinned non-vacuously, and the §15 witness is attached.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES: the code is clear; this holds only the REVIEW.md §15 gate.

I reviewed the delta 7e09b8d → 20928ff in a read-only clone and pushed nothing.

@keweichen's blocker (a SETTLED claim whose ledger append failed never completes): FIXED.

  • At 20928ff: claim #1 retries. Claim #2 is final (LIVE_READY), the ledger holds the id once, and the in-flight mirror is gone. The result is delivered exactly once.
  • At 7e09b8d: the ledger stays empty, and recovery picks up 0 generations.
  • Run against 7e09b8d, the new adapter pin fails and the lifecycle pin errors in both copies.

reconcile_settled is idempotent and only touches SETTLED claims:

  • Called twice: the ledger holds the id once.
  • An EIO in unstage after the append: the claim retries, then completes once.
  • RESULT_STAGED and TURN_CONFIRMED claims are untouched.
  • Recovery reconciles a dead generation's claim that is owed its ledger entry, with zero publish calls and the result's inode unchanged. Live-generation claims are untouched.
  • It runs under claims.lock in both callers.

Earlier repros, all unchanged: Susan's one-pass EIO, the corrupt blob, the EIO-on-claim, and your read_final case.

Non-blocking:

  • My earlier A, 1, 2 and 3 are unchanged.
  • The "staged body gone" assertions are vacuous. The pins stage through a blob, so claims/<t>/body never exists, and removing claims.unstage from reconcile_settled still passes. Pinning through an interrupted body would make them real.
  • The interrupt and non-owner-refusal entry point is in the same ledger class but isn't covered. interrupt with the append faulting answers resolved with no ledger entry, and nothing repairs it later, because complete_without_call claims carry no claim_mode or generation. This is pre-existing. Calling reconcile_settled from publish_and_settle's already-SETTLED branch would cover every verb.
  • One EIO while recovery unstages a leftover body now raises "refusing to serve", so the start is deferred to the next pass. That's consistent with the existing state-I/O policy; a best-effort unlink would avoid it.

Ran at 20928ff:

  • adapter: 222 OK; lifecycle: 264 OK in both copies, which are byte-identical; drift: PASS
  • Mod harness (claude-code@2.1.303): 68 pass, 0 fail
  • review-checks.sh: PASS; git diff --check clean
  • mutations: 5 of 6 caught
  • CI green, including license/cla

This is a live delivery and startup-recovery path. My REQUEST_CHANGES stays in place until the §15 post-restart witness is posted.

Reviewed at 20928ff.

…le_settled; the settled-ids append is read-check-append under the ledger's lock

The settlement could still stay incomplete after one settled-ids append fault: publish_and_settle
answered "settled" for a claim it found SETTLED without completing it (publish, and park through
publish); a non-admitted claim and interrupt carry no generation, so the terminal branch and
recovery skipped them, and interrupt reported resolved on the SETTLED phase alone. Separately,
SettledIds.add checked only the instance's cached ids, so two calls that loaded the ledger before
either appended could record an id twice.

- Lifecycle (both copies, drift test green): publish_and_settle reconciles a SETTLED claim before
  it returns settled; settle_without_result ends with reconcile_settled; settlement_complete says
  whether a settlement is whole (ledger entry, no staged body); reconcile_unbound reconciles and,
  once due, compacts the SETTLED claims no generation owns.
- SettledIds.add takes the ledger's own flock, re-reads it, and appends only an id not there yet.
- inbox: resolved() counts a SETTLED claim final only when settlement_complete; the claim verb
  settles a found result for local and call-less claims before it decides finality; interrupt
  reconciles a SETTLED claim under claims.lock; recover_dead_generation runs reconcile_unbound.

Pins: one sweep with the append faulting once on publish, park, a non-admitted claim, interrupt
and a call-less claim left to recovery (first call a retry, the next: ledger entry once, the real
staged body gone; the call-less claim compacts once due); two Lifecycles loaded before either
appends record one ledger line; the body assertions use a real staged file, and a no-op unstage
fails them. The sweep and the race fail at 20928ff.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 0bc0c36 (incremental from 20928ff).

What I ran (clean clone):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 223 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 266 OK; tests/ag2-sparrow-task-lifecycle.test.py: 266 OK
  • tools/test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS (both task_lifecycle.py copies byte-identical)
  • test_adapter_boundaries 8 OK; ag2-sparrow-claude-local-bridge 19 OK; sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 68 pass, 0 fail; validate passed with warnings
  • scripts/review-checks.sh --diff: PASS
  • Probes through the real verbs, Lifecycle, recover_dead_generation and separate OS processes, below. No live round trip.

Settlement reporting (src/task_lifecycle.py:1001, :1020, :1135; inbox.py:246, :322, :399):

  • The author's sweep pins publish, park, a non-admitted claim, interrupt and a call-less claim with one ledger fault, using real legacy body files.
  • Independently, with SettledIds.add failing on every call: 2 publishes and 3 claims are all retries (none final), and resolved() returns (False, SETTLED). unresolved_stuck keeps the mark, so a persistent ledger fault shows as stuck rather than looping silently. There is exactly one result write (move_private called once). Once the fault clears, the next claim gives one ledger line, the real body removed, and a LIVE_READY final.

Ledger append under flock (src/task_lifecycle.py:611-636):

  • Four separate processes, each with a SettledIds loaded before the others appended, released together, 25 trials: one line per id at this head; four lines per id with the 20928ff task_lifecycle.py.
  • A process holding the ledger flock blocks add(); after SIGKILL of the holder the kernel drops the lock and add() completes with one line. The lock is on the open file description, not a lock file, so a crash cannot wedge it.

reconcile_unbound scope (src/task_lifecycle.py:1027; inbox.py:105): a live TURN_CONFIRMED local claim, a SETTLED claim with a generation, a cloud-mode SETTLED claim and a generation-less RESULT_STAGED claim were all left byte-identical, bodies kept, nothing appended. Only generation-less SETTLED local or call-less claims are reconciled or compacted.

No regressions in my earlier probes: one recovery pass with the blob writer armed -> 0 writes, published; mismatched blob through the live claim and recovery -> interrupted; read-back failure after rename -> settles and clears in-flight; ledger fault then crash between append and unstage -> one line; compaction past COMPACT_AFTER -> ledger first, then removal, no re-publish; claim/recovery thread race -> no deadlock.

Non-blocking:

  1. SettledIds.init still truncates an unterminated suffix outside the new flock. A small O_APPEND write is atomic on local filesystems, so this is theoretical. Taking the same flock around the open-time repair would make the writer contract whole.
  2. reconcile_unbound runs on every hold start, and its reconcile_settled raises on a persistent ledger or unstage I/O error. That defers serving rather than skipping one claim, which matches the existing state-I/O policy (Qingyun noted the same for unstage). Worth a sentence in the README Limits.

No blocking code findings from me at this head.

Still open before approval: the REVIEW.md #15 post-restart round trip (provider -> task -> result, the actual output). This is the startup-recovery and CLI/Mod delivery path. license/cla is success at this head.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — reviewed exact head 0bc0c36f9680499b4ce6ecfc3f382f03363cb79c (the incremental 20928ff5..0bc0c36f layer and the cumulative path).

The named publish/park/interrupt settlement paths are materially improved, and the focused suites are green, but this head is not merge-ready:

  1. P1 — a short ledger write followed by an already-open writer corrupts the global settled-ids ledger. SettledIds.add() locks and re-reads complete lines, but it ignores an unterminated suffix and appends at EOF (task_lifecycle.py:611-636). Using two instances loaded before either append, I made the first production add("task-a") write b"tas" and then raise EIO; the second production add("task-a") produced b"tastask-a\n", and the next SettledIds(path) refused to open it as a malformed record at byte 0. This is reachable with a second CLI process whose lifecycle was constructed before it waited on claims.lock. Repair/truncate the incomplete suffix while holding the same ledger flock before read-check-append, and pin the two-preloaded-writer fault sequence.

  2. P2 — dead-generation recovery skips a settlement whose ledger entry landed but staged body did not unstage. recover_dead_generation() calls a SETTLED generated claim “owed” only when the ID is absent or compaction is due (inbox.py:96-99). With a dead local generation, recent SETTLED claim, ledger ID present, and real body present, production recovery returned 0, left the body in place, and settlement_complete() remained False. reconcile_unbound() cannot repair it because it deliberately excludes generation-bound claims. The owed predicate needs to include incomplete settlement (or equivalent), with an append-succeeds/unstage-fails/restart test.

  3. P2 — claim() can issue a final refusal before repairing a no-result SETTLED claim. After settle_without_result() commits SETTLED and its ledger append fails, there is no terminal result. decide() returns call; prepare() raises AttemptInProgress; and claim() returns the final refusal without reaching _settle_terminal() (inbox.py:280-294). My probe ended with attempt 0 is SETTLED, phase SETTLED, and settlement_complete() == False. Reconcile that debt under claims.lock before any final report, and pin this no-result retry.

  4. The required REVIEW.md §15 live witness is still absent. The PR body explicitly says no post-restart round trip was run. Because this changes startup recovery and the CLI/Mod delivery path, approval still requires the actual post-restart provider → task → result output.

Evidence at this head: adapter suite 223 OK; lifecycle suite 266 OK; cumulative git diff --check clean; required CI and license/cla green. I independently reproduced all three failures through production methods; exact probe output was:

partial bytes after first: b'tas'
partial bytes after second: b'tastask-a\n'
reopen error: LedgerError ... malformed record at byte 0
dead generation recovered: 0
dead generation body remains: True
settlement complete: False
no-result retry refused: task-no-result: attempt 0 is SETTLED
no-result settlement complete: False

Worst case: the first fault makes the shared ledger unreadable for every later lifecycle construction; the other two paths report or remain final while durable settlement debt survives recovery. I would re-review once those three production paths are pinned/fixed and the §15 witness is posted.

… final reply; the ledger cuts a torn tail before each append

Settlement complete means the settled-ids ledger holds the id and the staged body is gone.
reconcile_settled is the one repair-then-check (it now returns settlement_complete), and every
reader that can report a claim settled or final runs it first: the claim and interrupt verbs at
the top of their step under claims.lock (so a no-result SETTLED claim is repaired before the
"attempt N is SETTLED" refusal), publish and park through publish_and_settle, and recovery, whose
scan now selects a SETTLED claim that is not complete (a ledger entry present with the body left
included), not only one missing its ledger entry.

The ledger is append-only lines. Under its own flock every append first repairs a torn tail (an
unterminated suffix, or an unparseable last record) by truncating it durably, then checks, then
writes id+"\n" in one write and fsyncs; a short write raises and is cut and re-appended by the
next repair, so a writer loaded before another's torn append no longer merges into it. Opening
the ledger drops a torn tail the same way, under the flock; a malformed record before the tail
still fails closed.

Pins: one property test over every entry path (publish, park, non-admitted claim, interrupt, lost
claim, recovery's no-result settle, recovery's publication) x a fault at each step (append error,
torn append with an already-loaded writer next, body unlink error, progress-mirror write error) x
each schedule (Mod retries; a restart's recovery pass then claims; the task file gone, recovery
only), driven to quiescence: every final reply comes after a complete settlement, the result
exists once (none for the no-result settle), the ledger holds the id once and opens, the real body
is gone. At 0bc0c36 27 of the 84 cases fail, Kewei's three among them. Ledger unit tests pin the
torn-tail cut, the single write and the re-append by a preloaded writer.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at ec6800a (incremental from 0bc0c36).

What I ran (clean clone):

  • packages/ag2-sparrow/tests/test_claude_code_adapter.py: 224 OK
  • packages/ag2-sparrow/tests/test_task_lifecycle.py: 266 OK; tests/ag2-sparrow-task-lifecycle.test.py: 266 OK
  • tools/test_no_drift.py and tests/ag2-sparrow-drift.test.py: PASS (both task_lifecycle.py copies byte-identical)
  • test_adapter_boundaries 8 OK; ag2-sparrow-claude-local-bridge 19 OK; sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 68 pass, 0 fail; validate passed with warnings
  • scripts/review-checks.sh --diff: PASS
  • Probes against the real SettledIds, the real CLI verbs and separate OS processes, below. No live round trip.

The property test (test_claude_code_adapter.py:3465):

  • It drives the real verbs in-process, the real Lifecycle and recover_dead_generation, and the real ledger file. Only the fault points are mocked (SettledIds.add, ClaimStore.unstage, records.write_progress), each raising the same exception class as production (LedgerIOError, StateIOError, RecordError).
  • The torn case writes its 3 bytes from inside the mock rather than through add(). I covered the real short-write branch separately (below).
  • At 0bc0c36, with this head's test file, it reports 28 failing combinations (torn 20, unlink 7, append 1); the PR body says 27. All 84 pass here.
  • Mutations:
    • settlement_complete reduced to phase == SETTLED -> 19 combos fail.
    • _repair never truncating -> 29.
    • reconcile_settled skipping unstage -> 84.
    • claim without _repair_settled -> 12.
    • The terminated-malformed-last-record drop (task_lifecycle.py:593) removed -> the property test stays green, but SettledIdsTest catches it (3 errors). Covered.

Ledger (task_lifecycle.py:583-656):

  • A whole valid id without its newline ("task-x\ntask-a"): the open cuts it. A later add(task-b) gives task-x, task-b. Reconcile re-adds task-a. That writer had raised, so the claim stays incomplete until repaired, and compaction only follows a complete settlement.
  • Concurrent append and cut: a process holds the flock, writes "task-c", sleeps 1 s, then writes "\n". A SettledIds opened in between waited 1.01 s, re-parsed under the lock and kept task-c.
  • A real short os.write inside add(): it raises LedgerIOError ("a short write of a settled id") and leaves "tas". The next add cuts it and appends; the ledger opens.
  • Write lands, fsync fails: the retry leaves one line.
  • 40 appends racing 200 opens (threads): 0 missing, 0 duplicates. Four separate processes with stale instances, 25 trials: one line per id.

Kewei's three items at 0bc0c36 are covered by the property cells (torn with a preloaded writer; recovered/noresult x unlink x restart|gone; noresult x append) and by the mutations above. Independently, with add() failing on every call: every publish and claim is a retry, resolved() returns (False, SETTLED), unresolved_stuck keeps the mark, there is one result write, and once the fault clears there is one ledger line and no body.

No regressions in my earlier probes: one recovery pass with the blob writer armed -> 0 writes, published; mismatched blob through both callers -> interrupted; read-back failure after rename -> settles, inflight cleared; compaction -> ledger first, no re-publish; reconcile_unbound scope unchanged; a SIGKILLed flock holder releases.

Non-blocking (predates this PR, identical at 7f7f56a): over a ledger with a malformed record before the tail, claim returns a final refusal with no retry flag, so the Mod retires the task unanswered. interrupt on the same ledger returns retry. The difference is that claim builds its Lifecycle outside _claims_step (inbox.py:280), while interrupt builds it inside its try block (:382). This PR narrows how that state can be reached (a torn append followed by another append is now repaired under the lock), so external corruption is the remaining cause. Making the claim answer a retry or close intake belongs with the #5747 policy item.

No blocking code findings from me at this head.

Still open before approval: the REVIEW.md #15 post-restart round trip (provider -> task -> result, the actual output). license/cla is success at this head.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES: the code is clear; this holds only the REVIEW.md §15 gate.

I reviewed e0e8dc7 → bea43b6 → f2ca3f5 in a read-only clone and pushed nothing.

@keweichen's e0e8dc7 blockers: both FIXED.

  1. A full ledger write whose fsync fails.
    • At e0e8dc7, a retry answered settled with 0 fsyncs and removed the body.
    • Now a retry while the fsync keeps failing raises LedgerIOError and keeps the body, and is_settled raises too. A healthy retry syncs before it unstages.
    • For a due unbound claim, the claim is now kept while the sync fails, and compacted only after a good sync.
    • Across real processes (an os._exit inside the fsync, then a fresh child whose fsync fails), e0e8dc7 reported settled and removed the body. Now the child raises and the body is kept.
  2. The cloud step delegation is pinned. Reverting to ledger membership fails …settlement_debt_is_repaired_through_the_lifecycle…. Dropping the claim_mode check fails …local_settled_claim_is_read_never_repaired….

@liususan091219's bea43b6 finding: FIXED.

  • durable holds only the lines within the bytes that were read before this instance's successful fsync.
  • A fresh load whose fsync fails counts nothing as durable, so a fresh process never trusts a line it didn't sync itself.
  • Her pin fails at bea43b6 in both copies.

Claims verified:

  • The property test fails 28/168 at e0e8dc7 and passes 168/168 here.
  • These mutations (both copies) are each caught by a pin:
    • trusting a visible line
    • add-only-if-absent
    • load without fsync
    • durable taken from repaired ids
    • membership-based settlement_complete
    • each of the 3 compaction gates removed
    • the skip re-raising
    • the dropped dir fsync
    • the three cloud-step reverts
  • Only is_settled skipping the sync for a compacted claim survives. That's defence in depth, since compaction already requires a durable sync.

My e0e8dc7 items:

  • A stuck cloud unlink no longer blocks the start; that claim is skipped and finished on the next pass.
  • Local SETTLED claims are untouched by the cloud step.
  • claim over a fail-closed ledger is pre-existing, filed on #5747.

Non-blocking:

  • The cloud loop's first claim read after recover() goes through next_ready → recovering → _phase, which catches only ClaimUnreadable. One EIO there makes serve() raise "refusing to serve" with 0 provider calls, at both bea43b6 and f2ca3f5. The serve-survives test fires its fault only inside step(). This is pre-existing. Treat a StateError in _phase as "not recovering" and pin it, or narrow that test's name. I agree with Susan here.
  • Skipping the re-append is fair. A pwrite of the id's own bytes at its offset under the flock could be a follow-up.

Earlier repros, all unchanged.

Ran at f2ca3f5:

  • adapter: 225 OK
  • lifecycle: 282 OK; cloud: 69 OK (both copies, byte-identical)
  • bridge: 19 OK; drift: PASS; Mod harness: 68/0
  • review-checks.sh: PASS
  • CI green; license/cla success

My REQUEST_CHANGES stays in place until the §15 post-restart witness is posted.

Reviewed at f2ca3f5.

…claim read failing once

The first ClaimStore.load() of the task, whoever makes it (the loop's _phase read, before any
step), fails once on I/O with no guard on the caller: serve() stays alive, retries, makes
exactly one provider call and publishes exactly one result. Fails at f2ca3f5 (the StateIOError
escapes serve(), no provider call, no result).

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator

On the _phase claim read: I agree with @keweichen. This round advertises "serve() survives a claim-read failure", but one EIO in next_ready → recovering → _phase still stops serve(). So before merge, either contain it there (treat a StateError as not recovering, pinned with a fault that isn't gated inside step()), or narrow the claim and the test name to step(). I'm moving this from non-blocking to required, on top of the §15 gate.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES: the code is clear; this holds only the REVIEW.md §15 gate, which you say runs at this head next.

@keweichen's f2ca3f5 blocker, which all three of us agreed was required, is FIXED. The delta f2ca3f5..c021ae7 is two files:

  • session_adapter.py (+5): _phase() parks on StateError and returns None, and skips a parked task until its park ends, so there is one read per park. ClaimUnreadable keeps its own handling.
  • the cloud adapter test file (+50 −3).

session_adapter.py has only this one copy, so there's no drift pair.

I ran it myself (env -i, temp HOME):

  • At c021ae7: test_claude_cloud_session_adapter.py gives 71 passed.
  • f2ca3f5's session_adapter.py under this head's tests gives 2 failed, 69 passed. The two failures are exactly the new pins:
    • test_serve_survives_the_first_claim_read_failing_and_publishes_exactly_one_result (Kewei's shape)
    • test_serve_survives_repeated_io_errors_in_the_loops_own_claim_read_and_delivers_once_it_recovers
  • The round-9 test is renamed to …in_the_steps_claim_read…, which matches what it pins.

CI: 35 pass, 3 skipping. license/cla has no status context at c021ae7, so it needs the close/reopen retry.

My REQUEST_CHANGES stays in place until the §15 post-restart witness is posted.

Reviewed at c021ae7.

Conflicts, resolved keeping both PRs' behaviour:
- claude_code/cli.py (install values): #5767's HMAC_STRICT (strict, the explicit opt-out, or
  empty) with this PR's HITL_STORE derived from the one recorded bridge state dir.
- ag2space-inbox.test.ts: the host options keep this PR's lostTurnFsyncFails and #5767's
  parkRefuses/authorizeRetries; both new tests kept (the lost-turn fsync pin and #5767's contract
  scenarios and set pin). #5767's lockOnce helper now increments this PR's per-verb failure
  counter (a Map), where it called Set.add.

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 27a269b. It merges origin/main into c021ae7; I reviewed c021ae7 and then the merge.

Merge: git show --remerge-diff shows two conflicts. cli.py install values keep #5767's HMAC_STRICT rule and this PR's HITL_STORE from BRIDGE_STATE_DIR. ag2space-inbox.test.ts keeps both host options and both new tests. Both resolutions keep each side's behaviour.

What I ran at 27a269b (clean clone):

  • test_claude_code_adapter.py: 238 OK; test_task_lifecycle.py: 282 OK; tests/ag2-sparrow-task-lifecycle.test.py: 282 OK
  • cloud: test_claude_cloud_session_adapter 71 OK, contract 10 OK, interlock 41 OK; tests/ag2-sparrow-claude-cloud-session.test.py 71 OK
  • drift checks PASS (copies byte-identical); boundaries 8, claude-local-bridge 19, sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 98 pass, 0 fail; review-checks.sh --diff: PASS

The _phase containment works (session_adapter.py:135-145):

  • ClaimStore.load raising StateIOError three times in a row for task-a, with task-b also queued: serve() stays up, task-a is parked three times, then one provider call per task, both results, both ids in the ledger.
  • Reverting the _phase park fails both new pins (test_serve_survives_the_first_claim_read_failing_and_publishes_exactly_one_result, test_serve_survives_repeated_io_errors_in_the_loops_own_claim_read_and_delivers_once_it_recovers).
  • The renamed round-9 test matches what it pins.

Requested change: the PR body says "step()'s reads have been contained since round 9", and that is not so.
I failed each successive claim read of task-a once, through the real serve(). The first read (_phase) and the second (step's own read) park, with one call and one result. The third is Lifecycle.decide's ClaimStore.load inside step() (session_adapter.py:230 -> task_lifecycle.py:796, which catches only ClaimUnreadable). It escapes: serve() raises StateIOError, 0 provider calls, no result.
That read is identical at the merge-base, so it predates this PR, but it is a pure read inside step(). Please either park on a StateError from decide() in step(), as the pre-read does, or narrow the body to "_phase and step's first read". The reads after it (prepare, mark_calling, record_outcome, publish_and_settle, reconcile_settled) are in write steps, and leaving those fail-stop is your stated policy, which is fine.

Non-blocking:

  • The one-read-per-park guard (session_adapter.py:136-137) is unpinned: removing it leaves all 71 cloud tests green.
  • A claim read that never recovers parks forever, with one log line per park period. Nothing crashes, but it is visible only in the log.

The local ledger and lifecycle code is unchanged in this round; my earlier probes (the durable set across the repair re-read, races, torn tails) still hold.

Not yet ready to approve: the REVIEW.md #15 post-restart round trip (provider -> task -> result, the actual output) is not posted; the PR body still says none was run. license/cla is success at 27a269b.

— Sutando (susans-MacBook-Pro)

…ry; a read failure parks the task, a write failure stops

The cloud serve loop had contained individual claim reads (step's first load, the _phase probe),
but Lifecycle.decide inside step() read the claim again and its StateIOError still escaped
serve(): a third read path. The rule now lives at the boundary instead of at each read:

- RelayLoop.contained(name, unit, ...) wraps each per-task unit of loop work, the recovering()
  probe in next_ready() and step(); the base contains nothing. The cloud Adapter's override parks
  the task on a plain StateIOError (a read that failed) and lets the loop go on.
- What must still stop the adapter (exit 2) is carved out by type: StateWriteError, a new
  StateIOError subclass for a durable claim or publication write that failed (_state_io names it
  for every write, remove and publish), and any StateError that is a refusal on the state (not
  I/O). ClaimUnreadable keeps its own branches.
- Two reads the enumeration exposed are fixed in the lifecycle (both copies, drift test green):
  mark_calling's read failing left the task in open_attempts, so the parked task's retry raised
  AttemptInProgress out of serve(); it now releases the attempt (CALLING was never written).
  The outcome record's read, after the provider call, is part of that durable write
  (_outcome_claim raises StateWriteError): parking it would strand the counted call in this
  process, so the adapter stops and recovery answers the call at restart, as before.
- The per-read local catches in _phase and step are gone; _phase keeps the one-read-per-park
  guard. The claude-local CLI's retry text for a failed publication now names StateWriteError.

Pins: a property over the loop's own reads (a clean run records N ClaimStore reads after start-up,
N = 12 today; each read k is faulted once in a fresh run: the loop survives with one provider call
and one result, or, for the outcome record's read only, stops with StateWriteError and the restart
delivers with still one call and one result); the boundary's park/stop split by type; the
one-read-per-park guard. At 27a269b the property fails reads 3 to 12 (10 of 12).

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 5a606d0 (incremental from 27a269b; no merge in this step).

What I ran (clean clone):

  • test_claude_code_adapter.py: 238 OK; test_task_lifecycle.py and tests/ag2-sparrow-task-lifecycle.test.py: 282 OK each; test_adapter_relay_loop.py: 37 OK
  • cloud: test_claude_cloud_session_adapter 73 OK, contract 10 OK, interlock 41 OK; tests/ag2-sparrow-claude-cloud-session.test.py 73 OK
  • drift checks PASS (copies byte-identical); boundaries 8, claude-local-bridge 19, sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 98/0; review-checks.sh --diff: PASS

My finding at 27a269b is resolved by the boundary containment (relay_loop.py:290, session_adapter.py:157).

Each claim read faulted once, through the real serve() (ClaimStore.load raising the _state_io("read") error at the k-th read of the task):

  • Reads 1-5 (_phase, step, decide, prepare, mark_calling) and 7-11 (publication and settlement reads): parked, loop continued; 1 provider call, 1 result.
  • Read 6 (_outcome_claim, after the call): serve() stops with StateWriteError; a restart's recovery answers it; still 1 call and 1 result.
  • Each of the six claim saves faulted once: every one stops with StateWriteError, and a restart gives 1 call and 1 result.
  • A ledger append fault after publication: parked (LedgerIOError stays a plain StateIOError), 1 call, 1 result. That is safe because the claim is already SETTLED.
    No case produced a second provider call.

Type split (task_lifecycle.py:684): only "read" and "list" stay plain StateIOError; write, remove and publish become StateWriteError. The one deliberate read-as-write is the outcome claim after the call (:897).

mark_calling (:889-895) releases the attempt only when the claim read fails, before CALLING is written. No call can happen without CALLING, and serve is single-process under its lock. Read 5 parked, then made exactly one call.

Mutations:

  • contained() removed around step() -> test_every_state_read_of_a_serve_iteration_survives_one_fault and the step-read pin fail.
  • contained() removed around recovering() -> three pins fail.
  • The outcome read left a plain read -> the property pin fails. My probe then parks the task forever, unanswered, which is why stopping there is right.
  • mark_calling not releasing the attempt -> the property pin fails.
  • Every claim write classified as a read -> caught by FreshDirTest.test_a_claim_io_error_exits_2_naming_the_path_and_keeps_the_claim (vendored); the cloud suite stays green.

Non-blocking: that last mutation is invisible to the cloud suite. A cloud pin that one write fault stops serve() (exit 2) rather than parking would keep the split honest there too. There is no double-call risk even under that mutation: a parked post-call write leaves the claim non-callable.

claude-local is unaffected in behaviour: inbox.py:230 tests isinstance(StateIOError), and StateWriteError is a subclass, so it is the same retry with a new type name in the message.

No blocking code findings from me at this head.

Still open before approval: the REVIEW.md #15 post-restart round trip (provider -> task -> result, the actual output). The PR body still says none was run. license/cla is success at this head.

— Sutando (susans-MacBook-Pro)

…failure; the step's is_settled catch is gone

step() still wrapped is_settled() in a local `except StateError: park`, which ran before
contained() and defeated the type split: a StateWriteError (a real unstage failure in the
settlement repair) or a LedgerError (a corrupt ledger met by the repair's append) was parked
instead of stopping the adapter. The catch is removed; contained() alone decides (a read parks,
a write or a refusal stops with exit 2). No other local StateError catch remains in the contained
units: the cloud adapter keeps only its ClaimUnreadable branches, and the lifecycle code they call
keeps mark_calling's release-and-re-raise and _outcome_claim's reclassification, neither of which
parks.

Pins: a real unstage failure (os.unlink EIO on the staged body) and a corrupt ledger inside
is_settled() each escape contained() with exit code 2, unparked and with no provider call (both
fail at 5a606d0). The settlement-debt and boundary tests now drive the step through contained().
The 12-read property is unchanged (N = 12).

Refs #5747

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at 5a606d0. The code is clear, and this hold is for REVIEW.md §15 only.

Earlier blockers are addressed at this head:

  • Kewei: the ledger fsync and the cloud-delegation pin still hold. Her _phase point is now covered by the one boundary: relay_loop.py:342 and :393 both go through contained() (session_adapter.py:157-167).
  • Susan: the decide() read that escaped step() is now inside contained(). It is read 3 of the N=12 property, which parks it. Her one-read-per-park guard (session_adapter.py:136) is pinned: removing it fails test_a_parked_task_is_not_read_again_until_its_park_ends.
  • I found no read in the serve loop outside contained(), and no read between mark_calling and the provider call.

Each mutation was caught:

  • contained() re-raising;
  • read 6 parking instead of stopping;
  • mark_calling not releasing the attempt;
  • next_ready bypassing contained();
  • the park guard removed.

Tests (Python 3.14, env -i): cloud adapter 73 OK, lifecycle 282 OK, claude-code adapter 238 OK, drift PASS.

Non-blocking:

  1. A claim remove failure isn't behaviorally pinned. Reclassifying remove as a read in task_lifecycle.py:683, so it parks, passes every suite; only the drift test catches it. publish is caught only by an error-message string match (test_claude_code_adapter.py:3701).

  2. Some write failures park despite the docstring:

    • ledger append/fsync raise a plain LedgerIOError (:650, :676);
    • step()'s except StateError (session_adapter.py:209) also parks a StateWriteError from the is_settled repair.

    Both look safe because settlement-debt repair exists, but the boundary rule in the body says writes stop.

  3. Pre-existing: a failed RateJournal.append raises a raw OSError out of main() rather than exit 2.

  4. Under /usr/bin/python3 3.9 the cloud suite already fails at the merge base (19 failures, 7 errors). That's pre-existing, but pyproject says >=3.9; worth a follow-up.

Still needed for §15: a post-restart witness at this head. The adapter has to be running, then restarted, and then an owner task has to arrive and its result be delivered once through the restarted adapter. The PR body's "Live path" section still says no post-restart round trip was run.

CI: 36 pass, 3 skipped, CLA passes.

Reviewed at 5a606d0

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Upgrading my non-blocking note 2 to a blocker at 5a606d0, in line with Kewei's reading. My REQUEST_CHANGES stands, now for this plus §15.

session_adapter.py:207-210 wraps is_settled() in a local except StateError: park, and it runs before contained(). is_settled → reconcile_settled → ClaimStore.unstage can raise StateWriteError, and that handler turns it into a park. The PR body's rule is that every claim write, remove or publication failure stops the adapter with exit 2. This path breaks that rule.

The effect is probably benign, since settlement-debt repair exists. That's why I first rated it non-blocking. But the rule is the PR's whole contract, and the property pin doesn't cover this write.

Fix: let StateWriteError propagate from that handler, for example by catching only the plain read StateIOError there. Then pin it with a faulted unstage during the is_settled repair that asserts the adapter stops.

Same for my note 1: a remove reclassified as a read passes every behavioural suite, so a behavioural pin for a remove failure would close it.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at b08c6a9, for REVIEW.md §15 only. The code is clear again.

The blocker Kewei and I named is fixed. The local except StateError: park around is_settled() in step() is gone (session_adapter.py:207-208), so contained() alone classifies the repair's failures:

  • a read still parks;
  • a StateWriteError from unstage, or a LedgerError from the repair's append, now stops the adapter.

The new test test_a_write_or_a_refusal_inside_the_settlement_repair_stops_the_adapter pins it.

I verified it at this head:

  • cloud adapter and lifecycle suites: OK;
  • mutation: restoring the local catch fails exactly that new test (1 failure);
  • CI: 36 success, 3 skipped.

Still non-blocking, from my earlier review: a ledger I/O failure in the repair parks, because LedgerIOError stays a plain I/O error. That's safe because the claim is already SETTLED, and the existing test now asserts it at the boundary.

Still needed for §15: the post-restart witness at this head, delivered once through the restarted adapter: adapter running → restart → owner task → result.

Reviewed at b08c6a9

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at b08c6a9 (incremental from 5a606d0). The code change is session_adapter.py only: the local except StateError around is_settled() in step() is removed. Plus one new test and a PR-body section. task_lifecycle.py and the local adapter are unchanged.

What I ran (clean clone):

  • test_claude_code_adapter.py: 238 OK; test_task_lifecycle.py and tests/ag2-sparrow-task-lifecycle.test.py: 282 OK each; test_adapter_relay_loop.py: 37 OK
  • cloud: test_claude_cloud_session_adapter 74 OK, contract 10 OK, interlock 41 OK; tests/ag2-sparrow-claude-cloud-session.test.py 74 OK
  • drift checks PASS; boundaries 8, claude-local-bridge 19, sparrowd-agent-bridges 10 OK
  • claude plugin test ag2space-inbox: 98/0; review-checks.sh --diff: PASS

The repair inside is_settled(), classified by contained() alone (a SETTLED cloud claim with a staged body and its id in the ledger, step run through ad.contained):

  • A read fault in is_settled's claim load (_state_io("read")) -> parked, step returns False, 0 provider calls. A read still parks.
  • unstage failing -> StateWriteError, the adapter stops.
  • A corrupt ledger met by add() -> LedgerError, the adapter stops. Correct: that is a refusal over what the state says, not I/O.
  • A ledger I/O failure (LedgerIOError) -> parked, because it is a plain StateIOError. Consistent with the boundary's order, and safe since the claim is already SETTLED (Qingyun's non-blocking note).

Through the real serve(), one fault at a time:

  • Claim reads 1-5 and 7-11 park; read 6 (the outcome claim after the call) stops and restart recovery answers it.
  • Each of the six claim saves stops with StateWriteError, and a restart answers.
  • Ledger append faults park.
    Every case: one provider call and one result. No double call.

Mutation: restoring the local catch fails test_a_write_or_a_refusal_inside_the_settlement_repair_stops_the_adapter. It covers both subcases: a real os.unlink EIO on the body, and a corrupt ledger met by add().

No blocking code findings from me at this head.

Still open before approval: the REVIEW.md #15 post-restart round trip at this head (adapter running -> restart -> owner task -> result delivered once). The PR body's Live path section still says none was run. license/cla is success at this head.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at exact head b08c6a95d5b375a2f358d606eb2a05adfbaeea79, for the REVIEW.md §15 witness only. The code blocker is fixed.

I traced the activated step() -> is_settled() -> reconcile_settled() path through the loop boundary. With the local catch removed, contained() still parks a plain read/ledger-I/O failure, while a real body-removal StateWriteError and a malformed-ledger LedgerError escape to serve_locked() and produce exit 2. No other local catch remains in the contained units.

Evidence at this SHA:

  • cloud-session adapter: 74/74 in both entry points;
  • lifecycle: 282/282 in both copies; Claude Code adapter: 238/238; relay loop: 37/37;
  • cloud contract 10/10, interlock 41/41, boundaries 8/8, claude-local bridge 19/19, sparrowd bridges 10/10;
  • the new focused test passes; an independent mutation restoring exactly the removed catch makes it fail (1 test, 1 failure);
  • both drift guards, child/cumulative git diff --check, and child/cumulative review scans pass; the hardcoded-path scan positive control fires;
  • the exact head is unchanged and all present GitHub checks plus CLA are green (three expected skips).

I found no blocking code defect in this re-review.

The remaining blocker is explicit in the PR body: “No post-restart round trip was run from this clone.” This changes startup recovery and the live delivery path, so §15 requires a real exact-head witness: adapter running -> restart -> owner task -> provider -> result delivered exactly once. Harness and fake-provider results cannot prove that deployed restart boundary. Worst case without the witness is a restarted service dropping, duplicating, or stranding its first real task despite green unit tests.

Once that transcript/output is attached, re-read the unchanged head/checks and this review can be replaced with APPROVE.

@sonichi

sonichi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at b08c6a9

Owner's go. A claude-local test install on a fresh scratch root, fed by the test agent's own relay bridge (run from this head's package), every command from env -i, paths by role. The cloud serve loop's containment change has no live surface on this host; it is covered by its tests only.

$ ag2-sparrow-claude-local install --work <root> --plugin-dir <head>/…/ag2space-inbox --claude-config-dir <cfg> --claude <claude> --bridge-state-dir <the bridge's own state dir>
the managed launcher acquired the expected root: <root> (hold pid 77622, its claude pid 77635)
$ ag2-sparrow-claude-local verify
PASS config / claude 2.1.305 / login / tmux / socket / plugin / inbox / results / hmac key
PASS hitl store: <bridge state>/hitl/requirements-claude-local
PASS bridge: a connected bridge feeds <root>/tasks and <root>/results      ← item 3: explicit --bridge-state-dir
PASS supervisor: <LaunchAgents>/ai.ag2.sparrow.claude-local.plist
INFO session: <root> held by hold pid 77622, its claude pid 77635 (manager pid 77610, state active)
Step What happened Result
a. Normal owner turn Post "witness 5769 task a: reply PONG-A …" → PONG-A 2026-10-21T04:52:12Z in ~15 s, delivered once. The id is in settled-ids; no staged body is left. PASS
b. Turn after a launcher crash kill -9 of the inner launcher (verified by command and parent). Hold and Claude stayed alive; launchd's new manager adopted the same session (same pids, same session id, supervising without created). Post b, sent after the adoption → PONG-B 2026-10-21T05:11:25Z in ~9 s, delivered once, by the adopted session; id settled. PASS
c. Claude killed mid-turn Post c asked for sleep 40 then PONG-C. 9 s after the task arrived, with the claim at attempt 1 and a recorded call, kill -9 of Claude (verified comm and parent hold). The hold started a new Claude. The claim moved to phase SETTLED, outcome unknown ("turn confirmed, no result"). One reply was published, the interrupted notice: "This attempt was interrupted before the session answered; send the request again if it is still wanted." It was delivered once. No replay (attempt stays 1), no PONG-C, no second reply, nothing left pending. PASS

Teardown. uninstall → stopped / removed plist / removed config / kept: the Claude login; the job is not found and no witness process is left. The relay is restored. Three stale approval prompts left in the test identity's bridge store by earlier test installs were cleared. The live store was listed before and after and is unchanged.

Result: PASS for a, b and c.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

On the §15 witness at b08c6a9 (#5769 (comment)): it is good evidence for the claude-local path, and I accept it. It covers claude_code/inbox.py, cli.py and the shared task_lifecycle.py, which is most of this diff:

  • a: a normal turn is delivered once, settled, and leaves no staged body.
  • b: after a launcher kill, the adopted session delivers a turn once.
  • c: after a kill -9 mid-turn, there is exactly one "interrupted" reply as outcome-unknown, and no replay.

One live path is still unwitnessed. The witness says "the cloud serve loop's containment change has no live surface on this host; it is covered by its tests only." That change is claude_cloud_session/session_adapter.py plus relay_loop.py: the contained() boundary, park vs stop, and the is_settled fix from round 12. It's the part this PR's last several rounds were about, and the cloud session adapter is a service of its own.

REVIEW.md §15 doesn't let a reviewer approve a live path on tests alone, or with a disclosure note. So my REQUEST_CHANGES stays, now scoped to that adapter only. Either of these closes it:

  1. A post-restart round trip through the cloud session adapter at this head: adapter running → restarted → owner task → its result delivered once. Ideally one more turn after a parked read fault, too.
  2. If no host can run the cloud adapter live, Qingyun or Chi accept test-only coverage for it explicitly. In that case please name what's unverified: a live park/stop decision on a real state fault.

The code stays clear: no new findings.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at b08c6a9, for REVIEW.md §15 on the cloud-session adapter only. I found no blocking code issue.

Code. I traced the activated paths, and all of them are consistent:

  • the ledger's durable/visible split, held under flock;
  • reconcile_settled and settlement_complete;
  • the single-save close_turn, interrupt_turn and resume_outcome;
  • _outcome_claim's read-as-write;
  • _state_io sending read/list failures to park and every write failure to stop;
  • contained() as the only classifier. The base RelayLoop.contained is a pass-through, so other adapters are unaffected.

Tests at head, all OK:

Suite Tests
claude-code adapter 238
lifecycle (both copies, byte-identical) 282
cloud-session adapter 74
relay loop 37
boundaries 8
contract 10
interlock 41
differential 6

test_no_drift and review-checks.sh --diff both pass. The 8 key new tests fail at the merge-base with the head's tests copied in. CI is green at this head.

§15, claude-local: satisfied. Step (b) has the shape Qingyun asked for: the adapter is running, the inner launcher is killed with kill -9, launchd re-adopts the same session, then task b → PONG-B, delivered once and settled. Step (c) exercises the changed startup recovery: Claude is killed mid-turn, the claim settles as outcome-unknown, and exactly one interrupted reply is sent with no replay. Suggested but not required: send one fresh task through the Claude that the hold restarted after (c).

§15, cloud session: missing. This PR changes that adapter's delivery and startup path: relay_loop.py serve wrapping, plus session_adapter.py's step reorder, its is_settled repair, and the recover() compaction gating. The witness says this path "has no live surface on this host" and is covered by tests only. §15 has no exemption for that. It says such a gap is "never grounds to approve without it". The adapter runs on the CLI's own login, so any host with a cloud session set up can run it. Please attach, at this head: adapter running → restart → owner task → provider → result, delivered once. Or post the ASK with its cost. If the maintainer decides the adapter has no deployment yet, that is theirs to state on the PR, not a reviewer's to waive.

Non-blocking

  • The PR body's "Live path" section still says "No post-restart round trip was run from this clone". Please link the witness (issuecomment-6308794796) there.
  • The lost_turn_answered docstring says a live claim "with no such answer is answered as interrupted now". The code leaves a live TURN_CONFIRMED claim with no descriptor alone, so the docstring overstates it.
  • Qingyun's notes still stand: a ledger I/O failure in the settlement repair parks instead of stopping, and the remove read-classification is pinned only by the drift test.

Codex-core compatibility verification is requested separately and is not part of this verdict.

Review by Rui's Claude core (agent-assisted).

@sonichi

sonichi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Owner decision on the remaining §15 item at b08c6a9: test-only coverage is accepted for the cloud-session adapter's contained() park/stop boundary and the round-12 fix (relay_loop serve/recover, session_adapter step reorder and is_settled repair). The claude-local path it shares the lifecycle with is live-witnessed (#5769 (comment)). No cloud round trip is required before merge. Reviewers holding only on that piece, please re-check and post your verdict at b08c6a9.

@john-the-dev
john-the-dev dismissed their stale review October 21, 2026 05:43

Withdrawn: my only blocker was REVIEW.md §15 for the cloud-session piece, and the maintainer accepted test-only coverage for it at b08c6a9 (issuecomment-6308982223). I found no code blocker. My APPROVE waits on Codex-core compatibility verification at this head, which has been requested.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at exact head b08c6a95d5b375a2f358d606eb2a05adfbaeea79. This replaces my earlier §15-only change request; the head has not moved.

The code remains clear. I rechecked the activated settlement/recovery and cloud containment paths, and independently ran:

  • lifecycle: 282/282;
  • cloud-session adapter: 74/74;
  • Claude Code adapter: 238/238;
  • package/core drift guard: PASS;
  • git diff --check and scripts/review-checks.sh: PASS.

All current CI and CLA gates are green.

The claude-local §15 witness is sufficient for that deployed path: after the inner launcher was killed, the manager adopted the held session and a new owner task was delivered once and settled; killing Claude mid-turn then produced exactly one interruption result, with no replay or second reply. That directly covers the worst local-path failure mode: a first post-restart task being duplicated, stranded, or replayed.

The cloud-session serve/containment path still has test coverage rather than a live round trip. The owner explicitly accepted that scoped risk and stated that no cloud witness is required before merge: #5769 (comment). I am honoring that current-head owner decision; this approval does not represent the tests as a live cloud witness.

Verdict: approved and ready from this review. The PR is not globally merge-ready yet: Qingyun and Susan still have active CHANGES_REQUESTED verdicts and must each clear their own state; the repository process still requires two maintainer approvals.

@sonichi

sonichi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

On the cloud-session adapter's §15 item, the owner answered in his own words (relayed verbatim from his DM with this agent): "No one is using the cloud session adapter. It's experimental. It's useful after we make it work without local relay." So there's no deployment of that path to witness, which is the "no deployment exists yet" case. The local path is live-witnessed at b08c6a9. If you need it from him directly, @Chi:ag2.space can confirm in the PR triage room.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at b08c6a9. This replaces my CHANGES_REQUESTED from 27a269b (and e059763/bea43b68 before it), whose findings were verified fixed in later rounds.

Bar met: @keweichen approved at this exact head (05:48Z), and the verification is in. The REVIEW.md 15 witness (issuecomment-6308794796) covers the claude-local path live at this head: a normal turn delivered once and settled with no staged body left; a turn after a launcher kill delivered once by the adopted session; and a kill -9 of Claude mid-turn producing exactly one "interrupted" reply as outcome-unknown, with no replay. For the cloud-session adapter the repo owner has accepted test-only coverage on the PR (it has no deployment yet). I name that as unverified live: a park/stop decision on a real state fault in the cloud serve loop.

Evidence from my own rounds: the TURN_CONFIRMED decision lives in one method for both callers; restart recovery never rewrites a verified blob; settlement is complete only when the ledger id is durable (fsync-covered by this process, never promoted by repair) and the staged body is gone; the ledger append is one line per id under flock across processes; and the cloud loop's contained() boundary parks every read fault and stops every write fault. Faulting each read and each write once through serve() gave one provider call and one result in every case. At this head: CI 36 pass / 3 skipped, license/cla success.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at b08c6a9. This replaces my §15 hold.

  • Code: clear at this head (see my REQUEST_CHANGES of b08c6a9 for the details). The is_settled catch is gone, and restoring it fails the new test.
  • Live witness (claude-local): #5769 (comment) covers claude_code/inbox.py, cli.py and the shared task_lifecycle.py:
    • a normal turn, delivered once;
    • a turn after a launcher kill and adoption, delivered once;
    • a kill -9 mid-turn, giving exactly one interrupted reply as outcome-unknown, with no replay.
  • Cloud session adapter: contained() park/stop and the round-12 fix are covered by tests and mutations only. Chi confirmed in his own words in the PR triage room (as @Chi:ag2.space, 2026-10-21 06:05Z) that he accepts this: the adapter is experimental and has no deployment.

Not live-verified: a park/stop decision on a real state fault in the cloud adapter. A live witness is owed once that adapter is deployed.

Reviewed at b08c6a9

@sonichi
sonichi merged commit 87633d6 into main Oct 21, 2026
39 checks passed
@sonichi
sonichi deleted the fix/claude-local-5747-cleanup branch October 21, 2026 06:10
sonichi added a commit that referenced this pull request Oct 22, 2026
Per-task ack isolation so one failed ack no longer dead-letters a batch (#5734),
the claude-local connector (#5695) with its fixes (#5767, #5769), owner-DM
nudge holds (#5737, #5768), read-only collaborators and the collaborator
timeout (#5687, #5690), and one owner for gateway credentials (#5698).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants