Skip to content

fix(task-orphan-check): one crash-safe archive-then-report transition, previewing the ask - #4399

Open
sonichi wants to merge 54 commits into
mainfrom
fix/orphan-check-preview-task-header
Open

sonichi wants to merge 54 commits into
mainfrom
fix/orphan-check-preview-task-header

Conversation

@sonichi

@sonichi sonichi commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

What

/task-orphan-check recovers tasks a crashed session left in tasks/: it archives them and sends the owner one recovery notice. This PR turns that step from agent-followed prose into one tested, crash-safe transaction.

  • Preview the ask, not the header. classify.py gives each row a preview (the task: value up to the system-instructions block, whitespace-collapsed, PREVIEW_CHARS = 100). The prose previously sliced the file header, so every preview read id: … envelope_hmac: ….
  • One executable owner of step 3. classify.py --apply (apply_plan()) runs the plan end to end, rooted in the resolved workspace whatever the cwd. It acts only on the plan the caller approved: --plan prints a plan_id over the moves, verdicts and generations, and --apply --plan-id refuses the whole run when the re-plan differs. Each file is re-classified right before its claim and again right after it, while the pass holds the bytes, so a task now worker-held, fresh or done stays live. The plan_id also binds the reported rows, pending journals and claims a recovery would act on. SKILL.md step 3 runs --plan then --apply --plan-id, with no hand mv or rename.
  • src/task_transition.py: the archive-then-report transaction.
    • Pre-flight: tasks/ and tasks/archive/ are pinned as directory handles, so a directory swapped for a symlink is refused, never followed. Every name must be bare and every source a regular file still at the generation the plan captured. Any failure means nothing moves and nothing is published.
    • Claim, then judge: each file is taken by one atomic rename into a private <name>.claim-<pid>-<token>, judged through its own descriptor, and placed without clobbering (.N on a taken name). A mismatch is linked back to its live name without replacing anything. If that name was retaken meanwhile, the claim is kept as a marked quarantine. No live name is ever unlinked.
    • Notice last: the notice is rendered from the final landed names and published at a reserved name through result_publish.publish_witnessed, which keeps a witness link of the same inode until the journal is gone, so a notice a consumer already retired is never sent again. Bodies over the adapter's 48 KiB ceiling are refused. The notice names only what durably landed.
    • Journal and recovery: a flock'd journal in tasks/archive/ records each stage. The claim name is recorded before the rename. The archive dir is fsynced before the claim is unlinked, and a claim already linked into the archive is only unlinked on recovery. The next pass finishes an interrupted one exactly once: one copy of every generation, one notice if and only if it committed. A claim is unlinked on recovery only when it is the same inode as its landing, and a failure after the archive link records the landing as unsynced instead of giving it back. Every notice ends with (ref <txn>), its journal's transaction id, and a file at the reserved name counts as published only when it carries that id, so another transaction's notice is never adopted, even one with the same text. Every .N is searched, and while a claimed file cannot be found, nothing is published and the journal is kept. Unjournaled claims left by dead processes are given back (swept), and quarantined ones are left alone.
  • src/task_archive.py (vendored copy in packages/ag2-sparrow/): claim_generation, place_claim, find_landed, and a directory-handle form of the no-clobber move. The path form the bridges use is unchanged.
  • src/result_publish.py (and its vendored copy): an exclusive=True commit that never replaces a committed name.

Why

A recovery notice whose every preview read the file header, so the owner could not tell which asks had been lost. Review then showed that hand-applied prose could double-notify, clobber an archived record, delete a task rewritten mid-move, or name the wrong file. Each of those is now a tested contract on the production code.

Tests

All tests drive the production code on a real filesystem. Named checkpoints (before_claim, after_claim, before_give_back) inject races, and stage-by-stage crashes cover recovery.

file tests
tests/task-transition.test.py 113
tests/task-archive.test.py 51
tests/task-orphan-check-classify.test.py 155
tests/result-publish.test.py 30
tests/gateway-delivered-retire-keeps-witness.test.py 17 (on #5857's lane)
tests/outbox-receipt-state.test.py 10

Diff coverage is above the 95% gate. Each newly covered refusal or recovery line was mutation-checked against its test.

Integration with #5857

#5857 (outbox lane for proactive sends) merged first, and this branch now carries main with it. The three conflicts were resolved as follows:

  • Gateway. fix(gateway): proactive sends go through the outbox; an uncertain send is parked, never resent #5857's lane is kept whole, including its delivered path (archive, else a hidden .<stem>.delivered-<ns>, never unlinked) and its no-clobber _reap_hidden_delivered. This PR's own rename-based retire/reap helpers are dropped. On top of the lane, the restart's orphan recovery reads a dead owner's claim's item by the lane's id (<stem>~<body digest>) through outbox.receipt_state, never read_item, which reads malformed JSON as QUEUED:
    • a DELIVERED item is hidden for the reaper and never re-queued;
    • a record that cannot be read cleanly keeps the claim for an operator;
    • only an item with no record goes back live.
  • scripts/census-d1.py. Main's anchor is kept, and --verify reports 0 rotted anchors.
  • tests/gateway-disposal-delegation.test.py. Main's mover list is kept.

tests/gateway-proactive-recovery-edges.test.py case 17 now expects a delivered claim found by orphan recovery to be hidden (not re-queued), then archived after one POST.

Live path

--apply runs at /startup and writes a delivered proactive result, so REVIEW.md criterion 15 applies. The exact-head live witness is still owed and needs the owner's go: a real /startup restart, one real owner delivery, and a second restart with no repeat.

🤖 Generated with Claude Code

Why non-terminal states go back

On dead-owner recovery a claim whose receipt is absent, QUEUED, READY or CLAIMED goes back to its live name so main's outbox settles it as designed: DELIVERED from the stored event id, PARKED as an unknown outcome, never a second POST (main's tests/gateway-proactive-crash-receipt.test.py pins this). PARKED moves to undelivered/ and is never live and never resent; DELIVERED is hidden; an unknown or unreadable record keeps the claim for an operator.

Review round: three orphan-recovery paths (head fb48bcd5)

All three are in _recover_orphan_proactive() and its helpers in packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py, the only copy of this code.

  1. A FIFO claim no longer wedges the restart. _claim_receipt() now opens with O_NONBLOCK (still O_NOFOLLOW) and rejects anything that is not a regular file through the opened descriptor. A non-regular claim reads unknown, so it is kept as its claim for an operator.
  2. Unchecked bytes never reach .delivered-* or undelivered/. _move_decided() first renames the claim to this process's own claim name (<stem>.sending.<pid>.<ns>). It checks the generation there and only then places the file. On a mismatch or a failed placement, the file goes back to its claim name, or stays under the staged claim name. A crash at any step therefore leaves a claim, and the next restart reads that claim's own receipt again. The reaper only ever sees bytes that were checked. The old .changed-* set-aside name is gone.
  3. A PARKED item is never sent again because its record changed. After a PARKED read, the body goes back live only when the second read returns QUEUED, the state an operator requeue publishes. absent or any other state keeps the file parked and logs it.

Regressions are in tests/gateway-delivered-retire-keeps-witness.test.py. Each one runs the real Lane.restart() → _recover_orphan_proactive():

  • test_a_dead_owners_fifo_claim_never_wedges_the_restart (finding 1)
  • test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes (finding 2: crash after the first real rename, then the next drain and restart)
  • test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered (finding 2: non-crash error path)
  • test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent (finding 3: receipts PARKED then absent)

Two existing tests asserted the old .changed-* name. They now assert that the file stays under a claim name.

Control: the new test file was run against the 62e5b15 source. All four new tests fail: the restart blocked on the FIFO; unsent B was hidden as delivered on both paths; the PARKED body was handed back live. The two updated tests also fail there, as expected. With the fix, the file passes 21/21.

Suites at the new head: gateway-delivered-retire-keeps-witness 21/21, gateway-proactive-recovery-edges all ok, outbox-receipt-state 10/10, task-transition 113/113, task-orphan-check-classify 155/155, gateway-longpoll-timeout 55/55, gateway-disposal-delegation 76/76, all 13 gateway-proactive-* suites and remote-gateway-bridge / remote-gateway-outbound-worker exit 0. git diff --check and scripts/review-checks.sh pass.

The exact-head live witness (restart → one owner delivery → second restart, no repeat) is still owed at this head, before approval.

Review round: sharpened requirements (head 6081402836ca959d7f9b89660d0e96774858d562)

This round builds on the fb48bcd5 section above. Nothing above is retracted.

  1. Claim reader (_claim_receipt). It reads through one O_NOFOLLOW | O_NONBLOCK descriptor and checks fstat() for a regular file before reading. The read is bounded at 1 MiB (_CLAIM_READ_LIMIT), and the descriptor is closed in a finally on every branch. Each of these inputs reads unknown and is kept as its claim: a FIFO, a FIFO with queued bytes (the bytes are not consumed), a directory, a device, a symlink, an oversized file, and a read error.
  2. _move_decided(). The generation is checked under this process's own claim name before any rename into .delivered-* or undelivered/, as in fb48bcd5. If the rollback also fails, the file stays under the staged claim name. A log line names that claim, and the next restart decides it again from its own receipt. No .delivered-* entry is ever left behind.
  3. PARKED handback. The body goes back live only on positive proof of an operator requeue. The second read must return QUEUED, for the same generation, with a resend_epoch above the one read before parking. The new outbox.receipt() returns (status, resend_epoch) from the one read receipt_state() already did; it is in src and in the byte-identical vendored copy. Every other outcome keeps the file parked and logs it.

New tests (tests/gateway-delivered-retire-keeps-witness.test.py, all through the real Lane.restart() → _recover_orphan_proactive() and real drains):

  • test_the_claim_reader_fails_closed_on_every_input_it_cannot_trust: subtests fifo, fifo-with-bytes, directory, device, symlink, oversized, read-error. Each checks that the reader does not block, returns unknown and closes every descriptor; regular-path inputs also go through a real restart.
  • test_a_replacement_at_every_durable_boundary_is_neither_archived_unsent_nor_lost: covers DELIVERED and PARKED × {crash after the stage rename, stage fails, replacement at the rename target, crash after the rollback, rollback and every way out fail}. Then the owner dies, and a real restart plus two drains follow. B is never hidden or archived without its POST, B is never lost, and A is never resent.
  • test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition: covers six interleavings after the PARKED read (record deleted, record corrupt, QUEUED with no requeue, DELIVERED, another generation at the parked path, another item), each through two real drains. In every case the file stays parked and nothing is POSTed.
  • Unchanged from fb48bcd5: test_a_dead_owners_fifo_claim_never_wedges_the_restart, test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes, test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered, test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent.
  • tests/outbox-receipt-state.test.py::test_receipt_carries_the_resend_epoch_only_a_requeue_advances, on the src and vendored copies.

Control. This file was run against a clean 62e5b15f checkout. 17 failures, including:

test_the_claim_reader_fails_closed... (case='fifo')            True is not false : the reader blocked
test_the_claim_reader_fails_closed... (case='fifo-with-bytes') True is not false : the reader blocked
test_the_claim_reader_fails_closed... (case='directory')       1 != 0 : every branch closes its descriptor
test_the_claim_reader_fails_closed... (case='device')          'absent' != 'unknown'
test_the_claim_reader_fails_closed... (case='oversized')       'absent' != 'unknown'
test_the_claim_reader_fails_closed... (case='read-error')      'absent' != 'unknown'
test_a_dead_owners_fifo_claim_never_wedges_the_restart         True is not false : the restart blocked opening a FIFO claim
test_a_replacement_at_every_durable_boundary... (DELIVERED, stage, crash-after)  'UNSENT B\n' unexpectedly found : unsent B hidden as delivered
test_a_replacement_at_every_durable_boundary... (DELIVERED, rollback, fail)      'UNSENT B\n' unexpectedly found : unsent B hidden as delivered
test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes            unsent B hidden as delivered
test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered      unsent B hidden as delivered
test_a_parked_claim_goes_back_live_only... (interleave='delete')     0 != 1 : kept parked for an operator
test_a_parked_claim_goes_back_live_only... (interleave='delivered')  0 != 1 : kept parked for an operator
test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent  True is not false : never handed back live

The other three failures are tests whose expected contract changed in this PR: the .changed-* name, and the 3-tuple returned by _claim_receipt. Some subtests pass at 62e5b15f. The symlink case is refused there by O_NOFOLLOW. The other boundary cells, and the corrupt / QUEUED-without-requeue / other-generation / other-item interleavings, are caught downstream by the old rollback or by the outbox's own source proof. They are pinned here so that this layer holds by itself.

Suites at 60814028: gateway-delivered-retire-keeps-witness 24/24, outbox-receipt-state 11/11, task-transition 113/113, task-orphan-check-classify 155/155, gateway-longpoll-timeout 55/55, gateway-disposal-delegation 76/76, outbox-operator-recovery 50/50. All 13 gateway-proactive-* suites, all outbox-* suites, all ag2-sparrow-* suites, remote-gateway-bridge and remote-gateway-outbound-worker exit 0. tools/sync_from_src.py --check reports in sync. git diff --check and scripts/review-checks.sh pass.

The exact-head live witness is still owed at this head, before approval.

Review round: evidence detail (head 8448cc2c6d87026d12711c6336b27e9b8bc40aa0)

This head changes only tests. The source is unchanged from 60814028, and nothing in the sections above is retracted.

  • Each claim-reader branch is its own named test. Each one runs under a main-thread SIGALRM deadline, must return unknown, must leave no descriptor open, and must read at most limit + 1 bytes. Cases that use real files also go through a real restart. The cases: test_claim_reader_fifo_without_a_writer, …_fifo_with_queued_bytes_consumes_none, …_directory, …_device, …_symlink_to_a_regular_file_leaves_the_target_untouched (bytes and mtime), …_symlink_to_a_fifo_leaves_its_writer_and_bytes_untouched (bytes intact, writer still writes), …_oversized_consumes_nothing (refused from fstat(), zero bytes read), …_bounds_a_file_that_grew_after_fstat (fstat() reports the size from before the file grew; exactly limit + 1 bytes are read, then it is refused), …_read_error, …_without_a_no_follow_primitive.
  • Why a main-thread deadline. The repo's .coveragerc uses concurrency = multiprocessing, which does not trace threads. The earlier thread-based reader calls therefore ran but were never counted, and that is why the diff-coverage gate failed at 60814028.
  • The boundary matrix now requires B to stay sendable. B is only ever under a claim name. It is not under .proactive-*, archive/ or undelivered/, and _delivered_copy_exists stays False. After the owner dies, the real restart and drains must POST B exactly once.

Collected and run at the new head: gateway-delivered-retire-keeps-witness 33 tests, OK; outbox-receipt-state 11 tests, OK.

The same files against a clean 62e5b15f checkout: gateway-delivered-retire-keeps-witness ran 33 tests with 20 failures; outbox-receipt-state ran 11 tests with 2 errors (outbox.receipt does not exist there). Failures at 62e5b15f:

test_claim_reader_fifo_without_a_writer                      AssertionError: the reader blocked
test_claim_reader_fifo_with_queued_bytes_consumes_none       AssertionError: the reader blocked
test_claim_reader_directory                                  AssertionError: 5 != 4 : every branch closes its descriptor
test_claim_reader_device                                     AssertionError: 'absent' != 'unknown'
test_claim_reader_oversized_consumes_nothing                 AssertionError: 'absent' != 'unknown'
test_claim_reader_bounds_a_file_that_grew_after_fstat        AssertionError: 'absent' != 'unknown'
test_claim_reader_read_error                                 AssertionError: 'absent' != 'unknown'
test_a_dead_owners_fifo_claim_never_wedges_the_restart       AssertionError: the restart blocked opening a FIFO claim
test_a_replacement_at_every_durable_boundary_… (DELIVERED, stage, crash-after)  ['.proactive-1.delivered-…'] != []
test_a_replacement_at_every_durable_boundary_… (DELIVERED, rollback, fail)      ['.proactive-1.delivered-…'] != []
test_a_replacement_at_every_durable_boundary_… (PARKED, stage, crash-after)     ['proactive-1-….txt'] != []   (B parked under A's item)
test_a_replacement_at_every_durable_boundary_… (PARKED, rollback, fail)         ['proactive-1-….txt'] != []
test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes           unsent B hidden as delivered
test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered     unsent B hidden as delivered
test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition (delete)     0 != 1 : kept parked for an operator
test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition (delivered)  0 != 1 : kept parked for an operator
test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent           True is not false : never handed back live
(contract changes in this PR) …_swapped_in_flight_and_unrestorable 3 != 2; …_way_back_is_retaken [] != ['UNSENT B\n']; …_symlink_has_no_generation 2- vs 3-tuple

Pass at 62e5b15f too:

  • The two symlink cases: O_NOFOLLOW already refused them there.
  • The no-O_NOFOLLOW case: receipt_state refuses it there.
  • At 62e5b15f the read-error case fails because the reader there never calls os.read, not because of the injected fault.

The suites listed for 60814028 pass again at this head: recovery-edges, task-transition 113, classify 155, operator-recovery 50, disposal-delegation 76, ci-covers-every-python-test 71.

Review round: A/B terminal proof and scanner inventory (head 635ee6aad0fd5f6e3ce2bac678176931ec6a910c)

This head changes only tests. The source is unchanged from 60814028.

What the regressions now prove.

  • Generation A stays terminal under its own key. In every cell of test_a_replacement_at_every_durable_boundary_is_neither_archived_unsent_nor_lost (DELIVERED and PARKED × five faults), A's outbox item proactive-1~sha256(A)[:16] keeps its state (DELIVERED or PARKED) through the fault, the owner's death, the restart and two drains. It still keeps that state after another full restart plus two drains, and A is never POSTed. This PR never infers cancelled anywhere. A claim that vanishes, is ambiguous, or conflicts with another generation stays PARKED or stays a claim for an operator; it is never handed back.
  • Generation B is delivered once. A second production drain claims B and delivers it exactly once under its own key, proactive-1~sha256(B)[:16] (asserted to differ from A's). That item reads DELIVERED and the file is archived. The extra restart and drains send nothing more and move neither record.
  • Scanner inventory (test_a_staged_claim_is_invisible_to_every_delivered_scanner). B sits under a staged claim name, proactive-1.sending.<pid>.<ns>, which is the same shape the drain gives its own in-flight claims. Every reader of results/ names that could take it for delivered was checked:
    • checked by the test:
      • gateway _reap_hidden_delivered (glob .proactive-*.delivered-*) leaves it alone and archives nothing.
      • gateway _delivered_copy_exists returns False. The dispatch veto uses this same probe.
      • gateway drain reads only live proactive-*.txt and POSTs nothing.
      • src/proactive_recovery._recovery_target and its vendored copy return None. That is the other bridges' startup sweep.
      • skills/pending-questions … drained() returns False: still pending, not drained.
    • covered by name:
      • skills/task-orphan-check/scripts/classify.py, src/dedup_soundness.py and skills/proactive-loop/scripts/check-dedup-targets.py match only exact <id>.txt[.sending] names.
      • src/health-check.py's backlog check skips any name whose suffix is not .txt or .sending.
      • The gateway's owner-mention probe reads only owner-mention-* stems.
      • Outbox delivered state is keyed by item id under .outbox-ag2space-proactive/items/, never by a results file name.
    • the only intended reader: _recover_orphan_proactive (glob proactive-*.sending.*) decides the claim again from its own receipt once the owner is dead.
  • Deadlines and target assertions run under normal collection (python3 tests/gateway-delivered-retire-keeps-witness.test.py, unittest discovery, no focused helper):
    • every test_claim_reader_* call runs under a 1.0 s main-thread SIGALRM deadline, and each restart in them under a 2.0 s deadline.
    • …_symlink_to_a_regular_file… asserts that the target's bytes and st_mtime_ns are unchanged.
    • …_symlink_to_a_fifo… asserts that the writer's queued bytes are still readable and that the writer end still writes.
    • …_oversized_consumes_nothing asserts zero bytes read, refused from metadata.
    • …_bounds_a_file_that_grew_after_fstat asserts exactly limit + 1 bytes read, then refused.

Commands (from the repo root of each checkout):

python3 tests/gateway-delivered-retire-keeps-witness.test.py -v   # this head: Ran 34 tests ... OK (34 "... ok")
python3 tests/outbox-receipt-state.test.py -v                     # this head: Ran 11 tests ... OK
# the same two files copied into a clean 62e5b15f worktree:
python3 tests/gateway-delivered-retire-keeps-witness.test.py -v   # 62e5b15f: Ran 34 tests ... FAILED (failures=20)
python3 tests/outbox-receipt-state.test.py -v                     # 62e5b15f: Ran 11 tests ... FAILED (errors=2): no outbox.receipt

The 20 failures at 62e5b15f are the ones listed for 8448cc2c above, with the same messages. Of the new tests, four pass at 62e5b15f as well:

  • the scanner inventory, which pins a property of the claim namespace rather than of the changed code;
  • both symlink cases, refused there by O_NOFOLLOW;
  • the no-O_NOFOLLOW case, refused there by receipt_state.

At 62e5b15f, 18 tests pass: those 4 plus 14 that already existed in this file. The 20 failures fall in 16 tests, because subtest failures count separately. Also run and passing at this head: hermetic-env-structural 26 and ci-covers-every-python-test 71.

Review round: retained-descriptor race and stranded requeue (head 921c06aaf3645fa879c40478af9018ea7cc5fd2d)

This round answers the re-review of 635ee6aa, which raised two P1s. The claim reader, already cleared, is unchanged. The _move_decided() design described in the earlier rounds is replaced by the following.

  1. A placed copy is a private copy of the bytes that were read.
    • The generation is now the sha256 of the exact bytes the receipt was read for.
    • _move_decided() reads the claim once and checks that digest. It then publishes those in-memory bytes as a new file at the semantic name, through result_publish.publish_text(..., exclusive=True). The claim's inode is never renamed into .delivered-* or undelivered/, so a writer holding the claim's descriptor can never reach a placed copy.
    • The claim's own inode is then retained, never unlinked, as .<stem>.decided-<digest32>-e<epoch>.<ns>.
    • Placement is idempotent by content: if a crash comes before retaining, the claim is found on the next restart, its identical placed copy is reused, and nothing is placed twice.
  2. Every restart reconciles each retained original (_reconcile_decided).
    • New bytes in the retained inode are a new generation, decided again from their own receipt. An unsent B written there is handed back and sent once.
    • The same bytes go back live only when the item reads QUEUED at a resend epoch above the epoch recorded at decision time. The parked copy is moved live if there is one; otherwise the bytes are published fresh.
    • The recorded epoch is advanced only after the hand-back commits, and "handed back" is logged only then. A crash at any point is retried on the next restart.
    • A requeued claim whose bytes already sit in undelivered/ (a crash between parking and retaining) has that copy retained, so it is never left parked for an item that has been sent.
    • Retained originals are hidden and read only by this reconciler. They are invisible to every scanner in the inventory, which is extended to cover them and result_publish's staging name.

New collected regressions (tests/gateway-delivered-retire-keeps-witness.test.py, all through the production Lane.restart() and real Lane.drain()):

  • test_a_writer_holding_the_claims_descriptor_never_reaches_a_placed_copy:
    • The writer's descriptor is opened before recovery runs, and B is written through it at a set point in the protocol.
    • Cuts, each for DELIVERED and PARKED: before-placement (the check has returned, the semantic commit has not run; this is the window from the review), after-placement with and without a crash, and after-retain with and without a crash.
    • After the owner's death, a restart and two drains must POST B exactly once under its own item key. A's item keeps its DELIVERED or PARKED state, and every placed copy of A is A's exact bytes. A further restart plus drains changes nothing.
  • test_an_acknowledged_requeue_of_a_body_being_parked_is_sent_once_after_any_crash:
    • The real src/outbox_cli.py requeue (built by _proactive_requeue_command) runs after recovery reads PARKED at epoch 0. It returns result: requeued with body: nothing-quarantined.
    • Two cuts are collected independently: after-the-move (crash right after semantic parking, before the second receipt) and before-the-hand-back (the receipt proved QUEUED, the same bytes and a newer epoch).
    • Each cut asserts that no "handed back" line was logged, that the item reads QUEUED/1, and that a restart plus four drains produce exactly one POST. The item ends DELIVERED at epoch 1 with undelivered/ empty, and a further restart plus drains sends nothing.
  • test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition: the interleaving set is now delete, corrupt, QUEUED-without-requeue, DELIVERED, and other_item_in_the_same_inode (the retained inode rewritten in place). Each is followed by two drains, a restart and a drain. A's parked copy survives once and nothing is POSTed.
  • Removed, because the old staging mechanics they pinned no longer exist: test_a_parked_claim_swapped_in_flight_and_unrestorable_is_kept_for_an_operator, test_a_replaced_claim_whose_way_back_is_retaken_is_never_archived_as_delivered, and the earlier five-fault replacement matrix. The descriptor test above replaces the matrix.

Control at 635ee6aa: this file was copied into a clean worktree there. Ran 33, FAILED (failures=13, errors=1):

writer_holding_the_claims_descriptor (DELIVERED, before-placement)     [] != ['UNSENT B']   (B archived as delivered, never POSTed)
writer_holding_the_claims_descriptor (DELIVERED, after-placement)      [] != ['UNSENT B']
writer_holding_the_claims_descriptor (DELIVERED, after-placement, crash) [] != ['UNSENT B']
writer_holding_the_claims_descriptor (PARKED, before-placement)        [] != ['UNSENT B']   (B left under undelivered/)
writer_holding_the_claims_descriptor (PARKED, after-placement)         [] != ['UNSENT B']
writer_holding_the_claims_descriptor (PARKED, after-placement, crash)  [] != ['UNSENT B']
writer_holding_the_claims_descriptor (DELIVERED|PARKED, after-retain[, crash])  [] != ['UNSENT B']   (4 cells)
acknowledged_requeue (after-the-move)          [] != ['parked body']   (stranded: no live file, no claim)
acknowledged_requeue (before-the-hand-back)    True is not false : no success logged before the hand back
parked_claim_goes_back_live_only (other_item_in_the_same_inode)  0 != 1 : A kept parked once (['other body\n'])
ERROR staged_claim_is_invisible_to_every_delivered_scanner        no _DECIDED_INFIX at 635ee6aa (the new namespace)

The four after-retain cells fail at 635ee6aa only because that head has no retain step, so B is never written. They are not evidence about the old code, and they are listed only so the count is complete.

At this head: Ran 33, OK (33 ... ok). Also passing:

  • gateway-disposal-delegation 76 (its known-movers guard is satisfied because placement goes through the shared publisher), task-transition 113, task-orphan-check-classify 155, gateway-longpoll-timeout 55, outbox-receipt-state 11, result-publish 30, hermetic-env-structural 26, ci-covers-every-python-test 71.
  • All 13 gateway-proactive-* suites, plus remote-gateway-bridge and remote-gateway-outbound-worker.
  • tools/sync_from_src.py --check (in sync), git diff --check, and scripts/review-checks.sh.

Tradeoff: retained originals are kept in results/ permanently. They are hidden, and there is one per orphan claim decided as DELIVERED or PARKED, which only happens on the restart-recovery path. Keeping them is what makes a later write through a retained descriptor recoverable, and it keeps every delivered notice's inode linked.

The exact-head live witness is still owed after code clearance.

Review round: refusal-path tests (head d15e91eb1aaa03e26d1add784f256e5cd6384345)

This head changes only tests; the source is unchanged from 921c06aa. At 921c06aa, diff coverage came out at 94%, because the refusal branches of the new reconciler and placer were not exercised. The new DecidedClaimEdges class adds ten direct tests, one per branch:

  • test_a_name_it_does_not_own_is_left_alone
  • test_an_unreadable_retained_original_is_kept
  • test_new_bytes_that_cannot_be_reclaimed_are_kept_where_they_are
  • test_a_requeue_is_not_handed_back_over_a_newer_live_file
  • test_a_requeue_already_live_is_only_recorded
  • test_a_requeue_with_no_copy_left_is_published_fresh
  • test_a_parked_copy_that_cannot_move_is_retried_later
  • test_a_copy_that_cannot_be_placed_leaves_the_claim
  • test_a_superseded_copy_needs_the_claims_own_bytes
  • test_a_claim_that_is_not_utf8_is_unknown

gateway-delivered-retire-keeps-witness.test.py -v results:

  • At this head: Ran 43, OK.
  • Copied into a clean 635ee6aa worktree: Ran 43, FAILED (failures=13, errors=10). The 13 failures are the ones listed for 921c06aa. The 10 errors are test_a_staged_claim_is_invisible_to_every_delivered_scanner plus 9 of the 10 DecidedClaimEdges tests, which call functions that do not exist at that head. test_a_claim_that_is_not_utf8_is_unknown passes there too.

Hosted CI at this head: 36 pass and 3 skipped, including diff coverage ≥ 95%. One rerun was needed for tests/skills/worker-pool/worker-config-directory.test.py, which this PR does not touch and which also fails on main.

Review round: the retained original stays hidden and is reconciled on every drain (head 8d3d80af968dcfe576689d53b4b239badb56e798)

This round answers review 5642405947 at d15e91eb, which found that .decided-* originals were reconciled only at startup, and that their first changed generation was renamed back into the delivery lifecycle.

Changes:

  1. The original never leaves the hidden namespace. When the reconciler finds new bytes in a retained original:
    • it publishes them as a private snapshot claim (<stem>.sending.<pid>.<ns>, exclusive, through result_publish) and decides that claim from its own receipt;
    • it then records the new digest on the original, renaming it only within .decided-, and only after the snapshot exists;
    • if an identical copy already exists (live, claimed, hidden-delivered, archived or parked), it only records the digest, so a crash between the two steps never publishes the same bytes twice.
  2. Ordinary drains reconcile. _post_proactive() calls _reconcile_changed_decided(), which lstats each retained original:
    • only an original whose (size, mtime_ns, ctime_ns) changed since last seen is read again;
    • the stat taken before the read is the one recorded, so a write during the read is seen on the next pass;
    • an unchanged original costs one lstat per drain.
    • Restart still runs the full reconcile, including the requeue-epoch check.
  3. Archive names no longer collide. A delivered proactive file is archived as <stem>-<ns>.txt. With the old whole-second name, a second delivery of the same stem within one second replaced the first archive. The new test hit this as B's archive turning into C.

New collected regressions (production Lane, DELIVERED and PARKED subtests):

  • test_a_generation_written_after_restart_returns_is_sent_once_by_ordinary_drains: B is written through the held descriptor after restart() returns. Six ordinary drains send B exactly once, with no restart. The original inode is never under a live, delivered, archive or parked name. A further restart plus drains does not repeat it, and A keeps its disposition.
  • test_a_generation_written_after_the_last_was_delivered_is_sent_and_leaves_its_archive: B is delivered first, including the second restart that the reviewed head needed. C is then written through the same descriptor. Drains, a restart and more drains send C exactly once. B's archive still holds B's bytes, B and C both read DELIVERED under their own keys, A keeps its disposition, and the original stays hidden.
  • test_an_unchanged_retained_original_costs_a_drain_only_a_stat: once the original has settled, drains never call the reconciler.
  • test_new_bytes_are_published_as_a_snapshot_even_when_they_cannot_be_recorded: this replaces the earlier "cannot be reclaimed" test. The snapshot is a different inode, and a second pass publishes nothing new.

Control: the file was copied into a clean d15e91eb worktree. Ran 46, FAILED (failures=5):

generation_written_after_restart_returns (DELIVERED)  [] != ['UNSENT B']   (B hidden through six drains)
generation_written_after_restart_returns (PARKED)     [] != ['UNSENT B']
generation_written_after_the_last_was_delivered (DELIVERED)  'UNSENT C\n' != 'UNSENT B\n'   (B's archive rewritten to C)
generation_written_after_the_last_was_delivered (PARKED)     'UNSENT C\n' != 'UNSENT B\n'
new_bytes_are_published_as_a_snapshot_even_when_they_cannot_be_recorded  [] != ['new bytes\n']

The cost test also passes at d15e91eb, trivially, because drains there never reconcile.

At this head:

  • gateway-delivered-retire-keeps-witness ran 46, OK.
  • Also passing: task-transition 113, task-orphan-check-classify 155, gateway-disposal-delegation 76, gateway-longpoll-timeout 55, outbox-receipt-state 11, result-publish 30, hermetic-env-structural 26, ci-covers-every-python-test 71, pending-questions 25.
  • All 13 gateway-proactive-* suites, including recovery-edges, plus remote-gateway-bridge and remote-gateway-outbound-worker.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: a failed reconcile is retried on the next drain (head fcf46a48dc6a2279451b6d8e75b42cbaa9a21dfd)

This round answers a gap found at 8d3d80af. _reconcile_changed_decided() recorded each retained original's stat whatever _reconcile_decided() did. If a snapshot could not be placed (disk full, EPERM), or a generation could not be read, the stat never changed again. Every later drain then skipped the file, and the new owner bytes stayed unsent until a restart.

Change:

  • _reconcile_decided() returns True only once its work has settled. It returns False when:
    • the read fails, or the bytes are still being written;
    • the snapshot cannot be placed;
    • the new digest cannot be recorded;
    • a proven requeue cannot be handed back.
  • The drain records the stat only after a True. A failed reconcile is therefore retried on the next drain, as exactly one reconcile of that file per drain: there is no inner loop and no retry storm, and refusal logs stay rate-limited.
  • _DECIDED_SEEN drops entries whose file is gone.

Retained originals are kept on purpose and never reaped. A .decided-* original is the only inode that a late descriptor can still modify. Deleting it reopens the race this PR closes, because a later write would land in an unlinked inode and be lost. Growth is bounded: there is one original per orphan claim decided as DELIVERED or PARKED during restart recovery, which ordinary sends never create. Each costs one lstat per drain once settled.

New collected regressions (production Lane, DELIVERED and PARKED subtests):

  • test_a_snapshot_that_cannot_be_placed_is_retried_by_a_later_drain: snapshot publication fails with ENOSPC.
  • test_an_unreadable_generation_is_retried_by_a_later_drain: _claim_receipt returns a None generation.

In both, B is written after restart. Three drains run under the fault, each reconciling the file exactly once and posting nothing. Then the fault clears, and an ordinary drain posts B exactly once. Further drains, a restart and a drain post nothing more, and A keeps its disposition.

  • test_a_retained_original_that_is_gone_leaves_no_seen_entry: once the original is gone, its seen entry is pruned on the next drain.

Control: the file was copied into a clean 8d3d80af worktree. Ran 49, FAILED (failures=5):

snapshot_that_cannot_be_placed_is_retried_by_a_later_drain (DELIVERED)  1 != 2 : each failed drain retries once, no storm
snapshot_that_cannot_be_placed_is_retried_by_a_later_drain (PARKED)     1 != 2 : each failed drain retries once, no storm
unreadable_generation_is_retried_by_a_later_drain (DELIVERED)           1 != 2 : each failed drain retries once, no storm
unreadable_generation_is_retried_by_a_later_drain (PARKED)              1 != 2 : each failed drain retries once, no storm
retained_original_that_is_gone_leaves_no_seen_entry                     key unexpectedly found in _DECIDED_SEEN

At 8d3d80af the second drain under the fault never reconciles the file, which is the skip described above. Each test stops at that first assertion, before its delivery check.

At this head:

  • gateway-delivered-retire-keeps-witness ran 49, OK.
  • All 13 gateway-proactive-* suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish and ci-covers-every-python-test exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

Review round: a drain's snapshot claim is retried until its decision lands (head 66f382bf1685ca2c6140e8a992f298012e3feee9)

This round answers a should-fix found at fcf46a48. When a drain published new bytes as a snapshot claim and then failed to decide it, the reconcile still returned True. The decision fails when _hand_back finds a newer file holding the live name, or when _move_decided returns None. The claim carries this process's pid, so the orphan sweep skips it, and _copy_exists made later drains publish nothing. The bytes then waited for a restart.

Change:

  • Snapshot claims are now named <stem>.sending.<pid>.s<ns>.
  • _reconcile_decided() first decides this process's pending snapshot claims for that stem again, and returns False while any remains. The next drain therefore retries, as one decision attempt per pending claim per drain.
  • The new-bytes branch also returns False while the claim it just published still exists after its decision.
  • A dead process's snapshot claims are ordinary dead-pid claims, and the next restart recovers them.

Retained originals, restated. A .decided-* original can come from two places:

  • restart recovery deciding an orphan claim as DELIVERED or PARKED, one original per such claim;
  • a drain, when a snapshot claim it published is decided terminal, meaning the snapshot's own receipt already reads DELIVERED or PARKED. That decision retains the snapshot's inode the same way, one original per such snapshot.

Neither is ever reaped. Deleting one reopens the race: a write through a descriptor still open on it would land in a deleted inode and be lost. Each costs one lstat per drain once settled.

New collected regressions (production Lane, DELIVERED and PARKED subtests):

  • test_a_snapshot_whose_hand_back_is_blocked_is_decided_again_by_a_later_drain: B is written after restart, while a newer live proactive-1.txt holds the name. Within four ordinary drains, with no restart, both bodies are sent exactly once. A further restart plus drains sends nothing, no snapshot claim is left, and the original stays hidden.
  • test_new_bytes_whose_digest_cannot_be_recorded_are_sent_once_and_recorded_later: recording B's digest on the original fails on the first drain. B is still sent exactly once, a later drain records the digest, and nothing repeats after a restart.
  • test_new_bytes_are_published_as_a_snapshot_even_when_they_cannot_be_recorded is updated: its second reconcile now settles, and exactly one copy of the bytes exists.

Control: the file was copied into a clean fcf46a48 worktree. Ran 51, FAILED (failures=2):

snapshot_whose_hand_back_is_blocked_is_decided_again_by_a_later_drain (DELIVERED)  ['NEWER C'] != ['NEWER C', 'UNSENT B']
snapshot_whose_hand_back_is_blocked_is_decided_again_by_a_later_drain (PARKED)     ['NEWER C'] != ['NEWER C', 'UNSENT B']

test_new_bytes_whose_digest_cannot_be_recorded_are_sent_once_and_recorded_later passes at fcf46a48 as well. That head already returned False when the digest could not be recorded, so this test pins existing behaviour rather than reproducing a failure.

At this head:

  • gateway-delivered-retire-keeps-witness ran 51, OK.
  • All 13 gateway-proactive-* suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish and ci-covers-every-python-test exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

Review round: dead owners' snapshots, unknown receipts, writer readiness, copy proof, archive no-clobber (head 8b2735af0ecdc08fde5e07694e30fc6f5777200d)

This round answers review 5642872000 at 66f382bf: four P1s and two P2s.

  1. Dead owners' snapshots are retried by ordinary drains.
    • Each drain runs _retry_pending_snapshots() once. It decides again every <stem>.sending.<pid>.s<ns> claim whose owner is this process or dead.
    • A live other owner's claim stays fenced.
    • This replaces the per-retained-original scan, so work is one decision per pending snapshot per drain, however many originals a stem has accumulated (P2).
  2. An unknown receipt is not settled. _reconcile_decided() treats state == "unknown" like an unreadable generation: it returns False and is read again on the next drain. This covers the exact (unknown, <valid digest>, None) shape that _claim_receipt() returns when the item record is unreadable.
  3. Retained writers go through readiness.
    • New bytes in a retained original are taken only once they are ready by result_ready.read_ready_result_with_identity (non-empty, unchanged under the read, and the same digest as the receipt read).
    • They must also be at least SETTLE_SEC old (_DECIDED_SETTLE_SEC). This is stricter than the 50 ms hold ordinary results get, because this writer never publishes whole.
    • A writer that pauses mid-body, or truncates first, gets nothing sent until it holds still. No empty live file is ever made.
  4. Copies are proven only by ownership.
    • _copy_exists() is removed. New bytes skip publication only when their own outbox item is already terminal (DELIVERED or PARKED, keyed by the exact item id), or when an immutable snapshot claim of the same stem already holds them.
    • Archive and parked lookups (_owned) match exactly <stem>-<digits>[.n].txt, so a longer stem's file never counts.
  5. The delivered archive never replaces an earlier one (P2). It is now a no-replace placement (undelivered_quarantine.place_as) over <stem>-<ns>.txt, then <stem>-<ns>.<i>.txt.

Test-harness note. tests/gateway-delivered-retire-keeps-witness.test.py sets gb._DECIDED_SETTLE_SEC = 0.0 at import. Every test there except the two readiness tests pins some other property and would otherwise wait 1 s per write. The readiness tests restore the real SETTLE_SEC and simulate the pause with os.utime. The archive-failure mocks in gateway-proactive-recovery-edges now also route the archive's no-replace move.

New collected regressions (production Lane):

  • test_a_dead_owners_snapshot_blocked_at_restart_is_sent_by_a_later_drain: a dead pid's snapshot is blocked at restart by live C. Ordinary drains send C, then B, each once, with no second restart. A live owner's snapshot (the parent pid) is never taken.
  • test_a_drain_decides_each_pending_snapshot_once_however_many_originals_its_stem_has: two retained originals and one blocked snapshot give one decision per drain.
  • test_an_unknown_receipt_for_the_recorded_bytes_is_retried_until_the_record_reads:
    • It asserts _claim_receipt returns ("unknown", <64-hex digest>, None).
    • Three drains send nothing. An operator's QUEUED/epoch 1 record then replaces the unreadable one, which changes no retained-file stat, and a later drain hands the body back and sends it once.
  • test_an_unknown_receipt_for_readable_new_bytes_is_not_settled (DELIVERED/PARKED): new bytes whose item record is unreadable are not sent until the record reads, then are sent once.
  • test_a_paused_writer_never_gets_a_prefix_sent (DELIVERED/PARKED): UNSENT, then a drain, then UNSENT B\n gives exactly ["UNSENT B"].
  • test_a_writer_that_truncates_first_never_leaves_an_empty_live_file (DELIVERED/PARKED).
  • test_a_longer_stems_archive_is_not_proof_of_a_copy (DELIVERED/PARKED): archive/proactive-1-longer-123.txt containing B does not stop B being sent once.
  • test_a_live_file_rewritten_after_the_reconcile_is_not_proof_of_a_copy (DELIVERED/PARKED): both bodies are sent once.
  • test_two_deliveries_of_one_stem_in_one_tick_never_share_an_archive: with time_ns frozen, both archives keep their own bytes.
  • test_a_staged_claim_is_invisible_to_every_delivered_scanner is updated. New bytes in a retained original are now sent once from a snapshot, where the old _copy_exists had taken the unrelated claim as proof. A live owner's claim is still left alone.

Control: the file was copied into a clean 66f382bf worktree. Ran 60, FAILED (failures=13):

dead_owners_snapshot_blocked_at_restart_is_sent_by_a_later_drain   ['NEWER C'] != ['NEWER C', 'UNSENT B']
drain_decides_each_pending_snapshot_once...                         2 != 1 : one decision per pending snapshot per drain
unknown_receipt_for_the_recorded_bytes_is_retried...                [] != ['parked body']
paused_writer_never_gets_a_prefix_sent (DELIVERED, PARKED)          ['UNSENT', 'UNSENT B'] != ['UNSENT B']
writer_that_truncates_first_never_leaves_an_empty_live_file (x2)   True is not false : no empty live file
longer_stems_archive_is_not_proof_of_a_copy (x2)                    [] != ['UNSENT B']
live_file_rewritten_after_the_reconcile_is_not_proof_of_a_copy (x2) ['OTHER'] != ['OTHER', 'UNSENT B']
two_deliveries_of_one_stem_in_one_tick_never_share_an_archive       ['C\n'] != ['B\n', 'C\n']
staged_claim_is_invisible_to_every_delivered_scanner                [] != ['UNSENT B']   (the false copy proof)

test_an_unknown_receipt_for_readable_new_bytes_is_not_settled passes at 66f382bf too. On that path, the old pending-snapshot retry already kept the reconcile unsettled. The recorded-bytes test above is the one that reproduces the review's shape.

At this head:

  • gateway-delivered-retire-keeps-witness ran 60, OK.
  • All 13 gateway-proactive-* suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: snapshot ownership, requeue-aware cache, no-replace hidden witness (head 9481d423388fa14ca6ab822fb62db5aec4093f73)

This round answers review 5643152569 at 8b2735af, findings 1, 3 and 4. Finding 2, the writer paused longer than SETTLE_SEC, is open: the options and my pick are in #4399 (comment), and that finding waits on the reviewer's choice.

  1. A snapshot is owned before it is relied on. _take_matching_snapshot() and the per-drain _retry_pending_snapshots() take a dead owner's snapshot claim by renaming it, without replacing, to this process's pid. Only after that do they record the retained digest or decide the claim. A live owner's pid fences it from every other drainer, so the proof can no longer be moved between the check and the commit.
  2. The cache sees requeues. _DECIDED_SEEN holds the stat, the item the original's bytes would be sent as, and that item's (state, epoch), all read before the reconcile. A PARKED e0 → QUEUED e1 requeue changes no retained stat, but makes the next drain reconcile and hand the body back. The cost per settled original per drain is one lstat plus one item-record read.
  3. The hidden fallback is no-replace. When the archive placement fails, the delivered file is placed with place_as over .<stem>.delivered-<ns+i>. It no longer replaces an earlier hidden witness.

New collected regressions (production Lane):

  • test_a_competing_drainer_cannot_move_a_snapshot_between_its_proof_and_the_commit: right before the digest commit, a competing drainer moves the dead owner's snapshot live, and C is published over it. B is still sent exactly once.
  • test_an_operator_requeue_of_a_settled_original_is_handed_back_by_a_drain: PARKED e0 settles. requeue_item_with_epoch then makes it QUEUED/1, and a later drain, with no restart, sends the body once, ending DELIVERED/1.
  • test_a_persistently_failing_archive_never_replaces_a_hidden_witness: with time_ns frozen and every archive placement failing, B and C are both sent, and both hidden witnesses keep their own bytes.

Control: the file was copied into a clean 8b2735af worktree. Ran 63, FAILED (failures=3), exactly the three new tests:

competing_drainer_cannot_move_a_snapshot...        'UNSENT B' not found in ['NEWER C']
operator_requeue_of_a_settled_original...           [] != ['parked body']
persistently_failing_archive_never_replaces...      ['C\n'] != ['B\n', 'C\n']

At this head:

  • gateway-delivered-retire-keeps-witness ran 63, OK. gateway-proactive-recovery-edges passes; its mocks also route the hidden placement.
  • All 13 gateway-proactive-* suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: changed generations are parked for the owner, born PARKED (head 242f06471d125269ce34dbad0e37b1dae4abb258)

This round builds finding 2 of review 5643152569 as the reviewer chose it: option A, with an immutable snapshot (#4399 (comment)). Findings 1, 3 and 4 are unchanged from 9481d423, and their evidence is the section above.

The contract:

  • The original is never sent. A retained .decided-* original stays hidden. It is never passed to deliver_one and never moved to a live proactive-*.txt name. No settle time can tell a paused writer from a finished one, so its changed bytes are never sent automatically.
  • Each changed generation is parked, in this order:
    1. The exact bytes are placed, no-replace, in undelivered/ under a name derived from their full SHA-256 (_park_intent_copy). Concurrent writers converge on one copy, and an existing identical copy is reused.
    2. The item is born PARKED through the shared writer outbox.publish_parked().
    3. The digest-pinned outbox_cli.py requeue … --body-sha256 release command is logged.
    4. Only then is the retained generation recorded.
  • The operator's release is the only send. Releasing a generation sends it once. Unreleased generations, prefixes included, stay parked.
  • outbox.publish_parked() is in the shared writer, src and vendored, byte-identical.
    • It creates the item already PARKED in one write under its item lock. It is never publish() followed by park_unclaimed(), so there is no READY moment.
    • It returns CREATED, ALREADY_PARKED (a retry, or a racing writer with the same payload) or EXISTS (any other existing record, which it does not touch).
    • Only requeue_item_with_epoch moves the item out of PARKED.
    • The gateway writes no PARKED record of its own.
  • _move_decided() retains before it places. The claim's inode is retained first, and the decided copy is placed second. A crash between them leaves a retained original whose reconcile completes the placement. The previous order let a crash return the descriptor-held inode to recovery as an ordinary claim, which could then auto-send B.
  • The readiness and SETTLE_SEC gate on this path is removed. The test file no longer overrides it.
  • Other changes: _proactive_envelope() is now the single builder of a proactive payload, used by the drain and by parking. tests/_helpers/proactive_lane.py's restart() now binds ARCHIVE_RESULTS_DIR as production does.
  • Placement in undelivered/. The copy is placed before the PARKED record exists. That directory is read only by the operator's restore, and restore requires a PARKED requeue, so no drain or recovery scan ever sees it.

New collected regressions (production Lane, unless noted):

  • test_a_writer_paused_past_settle_time_has_both_generations_parked_and_only_the_released_one_sent (DELIVERED/PARKED), the acceptance case:
    • The writer pauses 5 s, past the old SETTLE_SEC, after UNSENT, then completes UNSENT B\n.
    • Result: zero automatic posts, and two distinct parked copies, each with its own PARKED item.
    • Releasing only the full digest sends once, with no repeat after a restart. The prefix stays PARKED.
  • test_each_later_generation_is_parked_for_the_owner_and_sent_only_when_released (DELIVERED/PARKED): B is parked and released (sent once). C is then written through the same descriptor, parked, and released (sent once). B's archive keeps B's bytes, A keeps its disposition, and the original stays hidden.
  • test_a_crash_at_each_step_of_parking_converges_on_one_copy_and_one_parked_item: crash cuts after-copy, after-parked and before-commit, then a restart and drains. Nothing is posted, there is one copy and one PARKED item, the generation is recorded once, and the release sends once.
  • test_a_copy_whose_parked_record_fails_is_parked_on_a_later_drain, partial-failure order (a): the copy exists and publish_parked fails. A later drain parks it, nothing is posted, and the release sends once.
  • test_a_parked_record_that_exists_before_its_copy_is_completed_by_a_later_drain, partial-failure order (b): the item is already born PARKED before its copy and bookkeeping. A later drain completes both, nothing is posted, and the release sends once.
  • test_two_drainers_parking_one_generation_converge: a second drainer runs its whole reconcile between the first drainer's copy and its PARKED record, through the production publish_parked. Outcomes are [CREATED, ALREADY_PARKED], with one copy, one item and no post.
  • test_parked_generations_never_clobber_each_other: three generations give three parked copies. Releasing the middle one sends only it, and the others stay parked.
  • test_an_empty_generation_parks_nothing_and_never_makes_a_live_file and test_an_unknown_receipt_for_new_bytes_parks_nothing_until_the_record_reads, which also checks the ("unknown", <64-hex>, None) shape.
  • test_no_live_archive_or_file_is_taken_as_already_parked: a longer stem's archive and parked file do not stand in for B.
  • test_a_copy_that_cannot_be_placed_is_completed_by_a_later_reconcile (direct): the claim is retained even when its copy cannot be placed, and the next reconcile places it.
  • test_a_dead_owners_snapshot_is_owned_before_it_is_decided (finding 1, retry path): a competitor that moves the dead owner's snapshot live, with C published over it, cannot make B vanish.
  • Updated to the parked contract: the descriptor-held writer matrix (_settled_once), the fault-then-clear pair, and the scanner inventory. Each now expects B parked with no automatic post, then released and sent once.
  • Removed: tests that pinned automatic sending of changed generations (B-after-restart sent by drains, the paused or truncating writer gate, the blocked snapshot hand-back, the unrecorded digest sent once). Their intent is covered by the parked tests above.
  • tests/outbox-publish-parked.test.py (new, src and vendored):
    • the record is written once and is only ever PARKED;
    • a retry returns ALREADY_PARKED, and anything else returns EXISTS and is left untouched;
    • the operator requeue is its only way out;
    • 8 racing threads × 20 rounds give exactly one CREATED per round.

Control: both files were copied into a clean 9481d423 worktree.

  • gateway-delivered-retire-keeps-witness ran 63, FAILED (failures=26, errors=6).
    • The descriptor matrix, the paused-writer case (an automatic POST) and every parked case fail on "one immutable parked copy" or on an automatic post.
    • Six error, because publish_parked and _proactive_envelope do not exist there.
    • test_an_unchanged_retained_original_costs_a_drain_only_a_stat and test_a_retained_original_that_is_gone_leaves_no_seen_entry also fail there. That head's 1 s settle gate is no longer overridden by the test file, so it keeps re-reading a fresh original. This is an artifact of removing the gate, not evidence about those two properties.
  • outbox-publish-parked ran 4, FAILED (errors=8), because there is no publish_parked or BornParked there.

At this head:

  • gateway-delivered-retire-keeps-witness ran 63, OK, and outbox-publish-parked ran 4, OK.
  • Every test using _helpers/proactive_lane exits 0. That covers the gateway-proactive crash-receipt, dispatch-revalidate, lane-edges, no-trust-ok, outbox-thread, recovery-edges and unknown-parks suites, plus proactive-retry-bound.
  • All gateway-proactive-* and outbox* suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, result-publish, hermetic-env-structural, ci-covers-every-python-test, pending-questions and ag2-sparrow-drift exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: route-aware item identity (head 4c669c4d07f26a4136f7bab3e2247a82d2030499)

This round answers review 5643435669 at 242f0647. Option A was accepted there. The one new P1: two generations with the same visible body and different routes shared one outbox item.

Change:

  • The route the file names is part of the identity.
    • _proactive_item_id(stem, body, room, thread) keys an item on the room and thread the file itself names, as well as its body. _proactive_identity() parses those once from the file's own [channel:] and [thread:] markers.
    • A default (owner) room and a task's thread are re-resolved on every pass, so they stay out. An unmarked file therefore keeps its original key unchanged.
    • [dm-only] needs no separate handling: the marker parser already suppresses the redirect, so the room comes out None.
  • One identity end to end. Every caller now uses the route-aware identity: parking, the retained-original cache, recovery's _claim_receipt, and the drain. _send_proactive() requires the item id the drain computed from the claimed text. The item born PARKED is therefore the same item a release claims, dispatches and marks DELIVERED, and no body-only item appears beside it.
  • Upgrade compatibility. A marked claim written before this change is matched to its old body-only item only when that item's stored payload names the same room and thread (_route_matched_legacy_receipt). An in-flight send is never repeated across the upgrade, and a different route is never conflated with it.
  • What counts as parked. The success set is this generation's CREATED, or ALREADY_PARKED with the exact key and payload; the latter covers a retry after a crash and a second drainer. BornParked.EXISTS means another payload holds the identity, so the generation is not recorded and is retried.

New collected regressions (production Lane):

  • test_the_same_body_routed_elsewhere_while_the_first_is_parked_is_its_own_parked_item: [channel: !RoomOne…] and [channel: !RoomTwo…] with the same body. Releasing RoomTwo by its raw digest posts to RoomTwo once, and RoomOne stays PARKED. The released item is the one that is DELIVERED/1, with no body-only item and no orphaned requeued item. Nothing repeats after a restart.
  • test_the_same_body_routed_elsewhere_after_the_first_was_delivered_is_still_parked: RoomOne is released and delivered first. RoomTwo then gets its own parked copy and item, is not sent automatically, and on release posts to RoomTwo once. Both items reach DELIVERED/1.
  • test_the_same_body_in_another_thread_is_its_own_parked_item: [thread: $ThreadOne] and [thread: $ThreadTwo] give distinct items. The release posts once in ThreadTwo.
  • test_a_crash_after_parked_and_a_second_drainer_both_settle_through_already_parked: a crash before the digest commit, then a retry, settles through ALREADY_PARKED, records the generation once, and releases end to end. The two-drainer convergence test from the previous round still passes.
  • test_an_item_holding_another_payload_never_counts_as_this_generations_park: with EXISTS, the generation is not recorded and nothing is posted.

Control: the file was copied into a clean 242f0647 worktree. Ran 68, FAILED (failures=5):

same_body_routed_elsewhere_while_the_first_is_parked   [('!RoomOne:ag2.space', 'SAME BODY')] != [('!RoomTwo:ag2.space', 'SAME BODY')]   (the review's repro)
same_body_routed_elsewhere_after_the_first_was_delivered   0 != 1 : RoomTwo has its own parked copy                                    (unrecoverable)
same_body_in_another_thread_is_its_own_parked_item     'proactive-1~9add1a66f34afd22' == 'proactive-1~9add1a66f34afd22'                 (one shared item)
crash_after_parked..._through_already_parked           'DELIVERED' != 'absent' : no body-only item
item_holding_another_payload_never_counts...           the generation was recorded on EXISTS

At this head:

  • gateway-delivered-retire-keeps-witness ran 68, OK.
  • Also passing:
    • all 13 gateway-proactive-* suites, proactive-retry-bound, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker and gateway-voice-room-gate;
    • all outbox* suites, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

Hosted CI here shares main's hermetic-env-structural 240 s timeout, which a separate main-side PR addresses. The exact-head /startup witness is still owed after code clearance.

Addendum: main merged in (head eb78a27aa2d827e256cb92b39fead206f706e2e4).

  • The merge: origin/main was merged with a normal merge commit, no force. It brings in the hermetic-env-structural timeout fix (test(hermetic-env): parse each scanned file once and cache the scope check #5874).
  • The one conflict: main had moved the proactive item id into proactive_routing.proactive_item_id. The route-aware key from this round now lives there (src and vendored, byte-identical), and the gateway's _proactive_item_id delegates to it. The identity policy therefore stays in the shared module.
  • Suites on the merged tree:
    • gateway-delivered-retire-keeps-witness ran 68, OK.
    • All 13 gateway-proactive-* suites, proactive-retry-bound, disposal-delegation, remote-gateway-bridge, outbound-worker and voice-room-gate exit 0.
    • task-transition, task-orphan-check-classify, longpoll-timeout, every outbox* suite (including main's new outbox-unreadable-item-fail-closed), result-publish, hermetic-env-structural, ci-covers-every-python-test, pending-questions, status-ack* and ag2-sparrow-drift exit 0.
    • sync_from_src.py --check is in sync.

Review round: code-review notes on 4c669c4 (head d2e09a3a23c028007ae3d67184e42ac406710789)

  • Merged with main. origin/main was merged in at eb78a27a (addendum above), and the PR reads MERGEABLE.
  • Upgrade edge. A thread-marked file about a task, mid-send at the upgrade, was sent under its body-only item in the task's thread, because a linked task's thread replaces the marker's.
    • _route_matched_legacy_receipt() compared only the file's own [thread:] marker, so such a claim read "absent" and could post twice.
    • It now also accepts the stored payload when it names the room and thread the send resolves for this file (_proactive_envelope).
    • Regression: test_a_thread_marked_claim_mid_send_at_upgrade_whose_task_thread_won_is_not_sent_twice. A legacy DELIVERED item stored with the task thread, plus a dead owner's claim, give zero POSTs after restart and drains.
  • One marker reader. _marked_thread() (the item key) and _proactive_thread_root() (the send) now share _thread_marker(), where the first thread marker decides. They could disagree when a malformed marker came before a valid one.
    • Regression: test_the_marker_key_and_the_send_read_a_thread_marker_the_same_way covers a malformed marker followed by a valid one, a valid marker, a bare [thread], and no marker.

Control: the file was copied into a clean eb78a27a worktree. Ran 70, FAILED (failures=2), exactly the two new tests:

  • the upgrade edge posts again (one POST, where none is expected);
  • for malformed-then-valid, the key reads '$Valid' while the send reads None.

At this head:

  • gateway-delivered-retire-keeps-witness ran 70, OK.
  • All 13 gateway-proactive-* suites, proactive-retry-bound, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, every outbox* suite, status-ack*, ci-covers-every-python-test and hermetic-env-structural exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: coverage through the production path (head 0ac290e6abb2c0b48aeb25f6434a82e96ea6e783)

This head changes only tests. At d2e09a3a, diff coverage was 94%: the legacy-route match's refusal lines in remote_gateway_bridge.py were unrun, and src/proactive_routing.py was at 60%. Both are now exercised.

  • Through the real Lane.restart() and drains, each a dead owner's [channel:]-marked claim written before the upgrade, beside its body-only item:
    • test_an_upgraded_claim_whose_legacy_payload_cannot_be_read_is_kept_for_an_operator: nothing is sent, and the claim is kept.
    • test_an_upgraded_claim_matches_its_legacy_item_by_its_own_room_when_the_route_cannot_resolve: route resolution raises, the claim is matched by its own room, and it is never resent.
    • test_an_upgraded_claim_for_another_room_is_not_taken_as_its_legacy_item: sent once, to its own room.
  • Other refusal paths, called directly (ParkAndIdentityEdges):
    • bytes that are not UTF-8 are neither parked nor keyed;
    • a snapshot name this process does not own or cannot take is left alone;
    • a superseded parked copy is retained at the requeued epoch;
    • an existing placed copy is reused.
  • The shared key: tests/proactive-routing.test.py::test_the_item_key_names_the_files_own_route pins proactive_routing.proactive_item_id, the owning module's contract test. An unmarked file keeps the body-only key, and a marked room or thread gives a distinct item. The src module itself is widely used: slack-bridge, discord-bridge, telegram-bridge, health-check, remote-gateway-bridge and ask-owner all import src/proactive_routing.py. Only proactive_item_id has no caller in src, because its one production caller is the gateway, which uses the vendored copy. The two copies are byte-identical and kept in sync by packages/ag2-sparrow/tools/sync_from_src.py (line 72), so this contract test is what exercises the src copy of that function.

At this head:

  • gateway-delivered-retire-keeps-witness ran 77, OK, and proactive-routing passes.
  • Hosted CI: 36 pass, 3 skipped, including diff coverage ≥ 95%. It took two reruns, for reap-orphans and then worker-config-directory; neither file is touched by this PR, and both also fail on main.

The exact-head /startup witness is still owed after code clearance.

Review round: legacy upgrade recovery (head 86b2c86d6b7d674edb161e1fe8da0474d67a892e)

This round answers review 5643821276 at 0ac290e6: three upgrade-path gaps in the legacy (body-only key) match.

  1. One item from upgrade to settlement. _proactive_identity() now resolves the item. When the route key has no record, it keeps the body-only item that is this generation's (_legacy_item_for). Recovery, the drain, parking and the retained-original cache all use it. A send that the pre-upgrade code landed, with a dispatch mark or a recorded receipt, therefore settles under that one item, and it is never posted again under a new key.
  2. Exact route match. The stored room_id and thread_root must equal what this send resolves, and a missing thread means top level, never "any thread". A linked task's thread still replaces the marker's. When the route cannot be resolved, the file's own marks are compared exactly.
  3. Unusable legacy payloads fail closed. A payload that is malformed, a scalar, a list or null reads unknown, and the claim is kept for an operator. _recover_orphan_proactive() and the reconcile loop also guard each item, so one failure never stops the others.

New collected regressions (production Lane.restart() and drains):

  • test_a_send_landed_by_the_old_key_is_never_posted_again_under_the_new_one, cuts before-receipt and after-receipt:
    • The pre-upgrade sender is the real drain with the body-only key. It POSTs for real and dies at the cut.
    • Recovery and drains under the new code then make no second POST and create no new-key item. The legacy item settles as DELIVERED or PARKED.
  • test_no_thread_means_top_level_when_an_upgraded_claim_is_matched:
    • Old thread to top level gives 1 POST; top level to thread gives 1 POST in the new thread; thread $A to $B gives 1 POST.
    • Controls: top level to top level, and $A to $A, give 0 POSTs.
    • The task-resolved thread case is the existing mid-send test, with 0 POSTs.
  • test_a_legacy_payload_that_is_not_an_object_keeps_its_claim_and_recovery_goes_on, for [], null, 5, "text" and {: the claim is kept, and in the same restart another claim is still recovered and sent.

Control: the file was copied into a clean 0ac290e6 worktree. Ran 80, FAILED (failures=3, errors=4):

  • Old key: 2 != 1 : never posted again, for both cuts.
  • Old thread to top level: [] != [('!RoomOne:ag2.space', None)], the top-level send suppressed.
  • Non-object payloads: four AttributeError that aborted recovery.

At this head: origin/main was merged again with a normal merge commit, since main had moved.

  • gateway-delivered-retire-keeps-witness ran 80, OK.
  • All 13 gateway-proactive-* suites, proactive-retry-bound, proactive-routing, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, every outbox* suite, status-ack*, result-publish, ci-covers-every-python-test, hermetic-env-structural, pending-questions and ag2-sparrow-drift exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

Review round: complete legacy envelopes, one safe read, per-item drain isolation (head 4dad3726e1bcdecd89601f774bc984c068bffa4c)

This round answers review 5644016967 at 86b2c86d. origin/main was merged in again with a normal merge commit.

  1. Only a complete envelope is evidence. _legacy_envelope() accepts the legacy item's payload only when it is an object whose body equals this claim's body, whose room_id is a non-empty string, and whose thread_root is absent or a non-empty string. Only such an envelope can prove an exact match or a genuinely different route. Any other record keeps the legacy item and reads unknown, so the claim is kept for an operator, never resent, and no new-key item is created.
  2. One safe read.
    • New outbox.receipt_record() in src and vendored, byte-identical. It returns state, epoch and the record from one bounded (RECEIPT_READ_LIMIT, 4 MiB), non-blocking, O_NOFOLLOW read of a regular file, and the descriptor is closed on every path. receipt() is now built on it.
    • The gateway reads the legacy payload only through it, so nothing can be swapped in between the receipt and the payload.
  3. Per-item isolation in ordinary drains. Each retained original in _reconcile_changed_decided(), its pre-read included, and each pending snapshot claim in _retry_pending_snapshots(), is guarded. An uncertain item is retried on a later drain and never stops the live-file loop. Startup already guarded each item.

New collected regressions (production Lane.restart() and drains):

  • test_a_landed_old_key_send_whose_envelope_is_unusable_is_kept_and_never_resent, the semantic-invalid matrix:
    • The pre-upgrade sender's POST lands and the sender dies at before-receipt or after-receipt. Its record's payload is then replaced with each of: {}, a missing body, the wrong body, a non-string body, a missing room, a non-string room, an empty room, a non-string thread, an empty thread.
    • That is 18 cells. Each makes no second POST and no new-key item, and the claim is kept.
  • test_a_legacy_item_that_is_not_a_regular_file_never_blocks_or_stops_recovery: the legacy record is a FIFO, a directory, a symlink to a well-formed record, or a record past the read bound. Under a 2 s deadline, recovery never blocks, the claim is kept, and a sibling claim is still recovered and sent.
  • test_an_ordinary_drain_isolates_each_retained_original: retained reconciliation with a directory legacy item and a raising original. The healthy live sibling is sent, and both originals stay for later.
  • test_an_ordinary_drain_isolates_each_pending_snapshot: snapshot reconciliation with a raising snapshot. A sibling snapshot and the live sibling are still sent, and the raising one stays.

Control: the file was copied into a clean 86b2c86d worktree. Ran 84:

  • 18 of the 18 invalid-envelope cells fail, either on a second POST (2 != 1) or because the claim was not kept.
  • The FIFO case fails because recovery blocks past the deadline.
  • The oversized case fails because the claim is not kept: the unbounded read accepted the record.
  • Both isolation tests fail with no sibling sent ([] != …).
  • The directory and symlink legacy-record cases already failed closed at 86b2c86d, and are pinned here.

At this head:

  • gateway-delivered-retire-keeps-witness ran 84, OK.
  • All 13 gateway-proactive-* suites, proactive-retry-bound, proactive-routing, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, every outbox* suite, status-ack*, result-publish, ci-covers-every-python-test, hermetic-env-structural, pending-questions and ag2-sparrow-drift exit 0.
  • sync_from_src.py --check is in sync, and git diff --check and review-checks.sh pass.

The exact-head /startup witness is still owed after code clearance.

At 4dad3726 (a follow-up commit, tests only): the retained-isolation test's local helper was renamed from reconcile to raising_for_one. tests/delivery-core-enforcement.test.py reserves the name reconcile repo-wide for the (attempt) provider contract, and the old name failed hosted CI at 62435c23.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (97.6%): Missing lines 5770,5776,5880,5890-5891,5893,5925,6011
  • packages/ag2-sparrow/ag2_sparrow/result_publish.py (100%)
  • skills/task-orphan-check/scripts/classify.py (95.2%): Missing lines 182,299,692,705-706,708,720-721,799,885,887
  • src/outbox.py (98.1%): Missing lines 1020
  • src/proactive_routing.py (100%)
  • src/result_publish.py (100%)
  • src/task_archive.py (92.0%): Missing lines 195-196,200-202,215-217,296,334-335,398-399,418-419,466-467
  • src/task_transition.py (95.4%): Missing lines 234,249-250,313-314,343-344,517-518,546-547,560,568-570,584,599-600,636-637,658,664-665,686,692,696-697,842,887-889,931-932,936-937,940-941,1075
  • src/undelivered_quarantine.py (80.0%): Missing lines 131,147-149

Summary

  • Total: 1733 lines
  • Missing: 79 lines
  • Coverage: 95%

packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py

Lines 5766-5774

  5766                 continue
  5767         try:
  5768             _decide_orphan_claim(f)
  5769         except Exception as e:  # noqa: BLE001 - one item's failure never stops the others' recovery
! 5770             _log(f"proactive claim {f.name} could not be recovered ({diagnostics.describe(e)}); kept as its "
  5771                  "claim for an operator")
  5772     for r in list(RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*")):
  5773         try:
  5774             _reconcile_decided(r)

Lines 5772-5780

  5772     for r in list(RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*")):
  5773         try:
  5774             _reconcile_decided(r)
  5775         except Exception as e:  # noqa: BLE001
! 5776             _log(f"proactive {r.name} could not be reconciled ({diagnostics.describe(e)}); retried")
  5777 
  5778 
  5779 def _decide_orphan_claim(f: Path) -> None:
  5780     target = f.with_name(f.name.split(".sending")[0] + ".txt")

Lines 5876-5884

  5876     for r in RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*"):
  5877         try:
  5878             st = os.lstat(r)
  5879         except OSError:
! 5880             continue
  5881         key, seen = (st.st_dev, st.st_ino), (st.st_size, st.st_mtime_ns, st.st_ctime_ns)
  5882         present.add(key)
  5883         cached = _DECIDED_SEEN.get(key)
  5884         if cached is not None and cached[0] == seen and (

Lines 5886-5897

  5886             continue  # same bytes and the same item state and epoch: nothing to do
  5887         try:  # its receipt is read before the reconcile, so a change during it is seen next pass
  5888             item = _retained_item(r)
  5889             state = receipt(_proactive_outbox_root(), item) if item else None
! 5890         except Exception as e:  # noqa: BLE001
! 5891             _log_backoff(("proactive-decided", r.name), f"proactive {r.name} could not be read "
  5892                          f"({diagnostics.describe(e)}); retried")
! 5893             continue
  5894         try:
  5895             settled = _reconcile_decided(r)
  5896         except Exception as e:  # noqa: BLE001 - one original's failure never stops the drain
  5897             _log_backoff(("proactive-decided", r.name), f"proactive {r.name} could not be reconciled "

Lines 5921-5929

  5921         return False
  5922     if generation[:32] != m.group("digest"):
  5923         data = _read_claim_bytes(retained)
  5924         if data is None or hashlib.sha256(data).hexdigest() != generation:
! 5925             return False  # changed under the read: the next pass reads it again
  5926         parked_epoch = _park_generation(stem, data, state)
  5927         if parked_epoch is False:
  5928             return False
  5929         try:  # recorded only once its parked copy and PARKED record are durable

Lines 6007-6015

  6007         dest = quarantine / f"{stem}-{base + i}.txt"
  6008         if not os.path.lexists(dest) and _place_snapshot(data, dest, stem):
  6009             return dest
  6010         if _read_claim_bytes(dest) == data:
! 6011             return dest  # another writer placed the same bytes here first
  6012     return None
  6013 
  6014 
  6015 _SNAPSHOT_MARK = "s"

skills/task-orphan-check/scripts/classify.py

Lines 178-186

  178     m = _EPOCH_MS_RE.search(task_id)
  179     if m:
  180         return int(m.group(1)) / 1000.0, "id"
  181     if mtime is not None:
! 182         return mtime, "mtime"
  183     try:
  184         return path.stat().st_mtime, "mtime"
  185     except OSError:
  186         return 0.0, "mtime"

Lines 295-303

  295         line = build(*parts)
  296         if not result_markers.parse_markers(line).actions:
  297             return line
  298         parts = [_defang(p) for p in parts]
! 299     raise ValueError(f"could not make a safe line from {raw_parts!r}")
  300 
  301 
  302 _ARCHIVE_TAIL_RE = re.compile(r"\.txt(\.\d+)?$")

Lines 688-696

  688             verdict = row.get("verdict")
  689             if verdict == "done":
  690                 continue
  691             if verdict == "orphan" and (row.get("trusted_source") or "").strip() in ("voice", "phone"):
! 692                 continue
  693             groups[groups_for.get(verdict, "deferred")].append(row)
  694         if not any(groups.values()):
  695             return None
  696         return _final_body(groups, {row["file"]: landed[row["file"]] for row in groups["deferred"]})

Lines 701-712

  701     """The worker router's per-task lock, held around each claim so a delivery
  702     cannot interleave; None when the worker-pool skill is absent."""
  703     try:
  704         os.stat(_POOL_SCRIPTS / "pool_router.py")
! 705     except FileNotFoundError:
! 706         return None
  707     if str(_POOL_SCRIPTS) not in sys.path:
! 708         sys.path.insert(0, str(_POOL_SCRIPTS))
  709     import pool_router  # noqa: E402
  710     return lambda name: pool_router.task_arbitration(workspace, Path(name).stem)
  711 

Lines 716-725

  716     def check(name: str, read: str) -> str | None:
  717         try:
  718             row = classify_task(workspace / "tasks" / read, workspace,
  719                                 time.time() if now is None else now, as_name=name)
! 720         except OSError as exc:
! 721             return f"could not be re-classified ({exc})"
  722         if row.get("verdict") != verdicts.get(name):
  723             return f"now {row.get('verdict')}, not {verdicts.get(name)} as planned: {row.get('reason')}"
  724         return None
  725     return check

Lines 795-803

  795     ]
  796     body = "\n".join(lines) + "\n"
  797     parsed = result_markers.parse_markers(body)
  798     if parsed.actions or parsed.body.strip() != body.strip():
! 799         raise ValueError("recovery body is not inert against the production marker parser "
  800                          "despite every row's own line being verified -- a composition bug, "
  801                          "not a per-row escaping gap")
  802     return body

Lines 881-891

  881     if ws is None:
  882         print("orphan-check: workspace could not be resolved", file=sys.stderr)
  883         return 2
  884     if args.apply:
! 885         out = apply_plan(ws, args.now, approved=args.plan_id)
  886     elif args.plan:
! 887         out = recovery_plan(ws, args.now)
  888     else:
  889         out = classify_workspace(ws, args.now)
  890     print(json.dumps(out, indent=2))
  891     return 1 if out.get("errors") else 0

src/outbox.py

Lines 1016-1024

  1016                 break
  1017             chunks.append(chunk)
  1018             size += len(chunk)
  1019         if size > RECEIPT_READ_LIMIT:
! 1020             return "unknown", None, None
  1021         data = json.loads(b"".join(chunks).decode("utf-8"))
  1022     except (OSError, UnicodeDecodeError, ValueError):
  1023         return "unknown", None, None
  1024     finally:

src/task_archive.py

Lines 191-206

  191         except OSError:
  192             tmp = base.with_name(f".{base.name}.{uuid.uuid4().hex[:12]}.part")
  193             fd_in = os.open(str(src), os.O_RDONLY, dir_fd=src_dir_fd)
  194             if expect is not None and file_generation(os.fstat(fd_in)) != expect:
! 195                 os.close(fd_in)
! 196                 raise PlacementMismatch(str(src))
  197             try:
  198                 fd_out = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600,
  199                                  dir_fd=dst_dir_fd)
! 200             except BaseException:
! 201                 os.close(fd_in)
! 202                 raise
  203             try:
  204                 with os.fdopen(fd_out, "wb") as out, os.fdopen(fd_in, "rb") as inp:
  205                     shutil.copyfileobj(inp, out)
  206                     out.flush()

Lines 211-221

  211                 while True:
  212                     try:
  213                         os.link(str(tmp), str(candidate), src_dir_fd=dst_dir_fd, dst_dir_fd=dst_dir_fd)
  214                         break
! 215                     except FileExistsError:
! 216                         n += 1
! 217                         candidate = base.with_name(f"{base.name}.{n}")
  218             finally:
  219                 os.unlink(str(tmp), dir_fd=dst_dir_fd)
  220             break
  221     try:  # landed: from here a failure must not read as "not placed"

Lines 292-300

  292             _uq.renameat_noreplace(name, as_name, src_dir_fd=src_dir_fd, dst_dir_fd=retired_fd)
  293             break
  294         except FileExistsError as exc:
  295             if exc.errno != errno.EEXIST:
! 296                 raise OSError(errno.ENOTSUP, f"cannot retire {name} without overwriting: {exc}") from exc
  297     _checkpoint("after_retire", name)
  298     _fsync_dir(retired_fd)
  299     _fsync_dir(src_dir_fd)
  300     if expect is not None and file_generation(os.lstat(as_name, dir_fd=retired_fd)) == expect:

Lines 330-339

  330                    for m in [tail.fullmatch(e)] if m)
  331     for _, candidate in found:
  332         try:
  333             st = os.lstat(candidate, dir_fd=dst_dir_fd)
! 334         except FileNotFoundError:
! 335             continue
  336         if file_generation(st) == generation:
  337             return candidate
  338     return None

Lines 394-403

  394     _fsync_dir(dir_fd)
  395     live = file_generation(os.lstat(name, dir_fd=dir_fd))
  396     with retired_dir(archive_fd) as rfd:
  397         if expect is not None and live != expect:  # the claim was replaced before the link
! 398             moved = retire(name, src_dir_fd=dir_fd, retired_fd=rfd, expect=None)
! 399             return GenerationChanged(name, f"{why}; its claim was replaced, so that file is kept as "
  400                                            f"archive/{RETIRED_DIR}/{moved}", kept_as=claim)
  401         stray = retire(claim, src_dir_fd=dir_fd, retired_fd=rfd, expect=live)
  402     if stray:
  403         return GenerationChanged(name, f"{why}; a file that replaced its claim is kept as "

Lines 414-423

  414     import os
  415     keep = f"{claim}.displaced"
  416     try:
  417         out = os.open(keep, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dir_fd)
! 418     except OSError:
! 419         return None
  420     with os.fdopen(out, "wb") as fh:
  421         offset = 0
  422         while chunk := os.pread(fd, 1 << 16, offset):
  423             fh.write(chunk)

Lines 462-471

  462     is re-resolved and no live name is ever unlinked."""
  463     import os
  464     try:
  465         fd = os.open(claim, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=src_dir_fd)
! 466     except OSError as exc:
! 467         raise _give_back(claim, name, f"cannot be opened as a regular file ({exc})", src_dir_fd,
  468                          archive_fd=dst_dir_fd) from None
  469     try:
  470         judged = file_generation(os.fstat(fd))
  471         if judged != generation:

src/task_transition.py

Lines 230-238

  230 
  231 def _fsync_path(directory: Path) -> None:
  232     """A barrier on a directory named by path (results/ is not one of the pinned handles)."""
  233     if os.name == "nt":
! 234         return
  235     fd = os.open(directory, os.O_RDONLY)
  236     try:
  237         os.fsync(fd)
  238     finally:

Lines 245-254

  245     if name and txn:
  246         try:
  247             os.unlink(results_dir / _witness(name, txn))
  248             _fsync_path(results_dir)
! 249         except OSError:
! 250             pass  # a witness that outlives its journal is swept by a later pass
  251 
  252 
  253 def _publish_at(results_dir: Path, name: str, body: str, txn: "str | None" = None) -> Path:
  254     """Publish `body` at exactly `results_dir/name`, whole, never replacing; with

Lines 309-318

  309         while a live pass holds it. Unreadable data reads as {}."""
  310         try:
  311             lock_fd = os.open(f"{name}.lock", os.O_RDWR | os.O_CREAT | _NOFOLLOW, 0o644,
  312                               dir_fd=dirs.archive_fd)
! 313         except OSError:
! 314             return None
  315         try:
  316             fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
  317         except OSError:
  318             os.close(lock_fd)

Lines 339-348

  339             task_archive._fsync_dir(self.dirs.archive_fd)
  340         except BaseException:
  341             try:
  342                 os.unlink(tmp, dir_fd=self.dirs.archive_fd)
! 343             except OSError:
! 344                 pass
  345             raise
  346 
  347     def set(self, **fields) -> None:
  348         self.data.update(fields)

Lines 513-522

  513     """A quarantined claim is kept on purpose; the marker keeps the sweep off it."""
  514     try:
  515         fd = os.open(claim + QUARANTINE_SUFFIX, os.O_WRONLY | os.O_CREAT, 0o644, dir_fd=dirs.tasks_fd)
  516         os.close(fd)
! 517     except OSError:
! 518         pass
  519 
  520 
  521 _CLAIM_RE = re.compile(r"^(?P<live>.+)\.claim-(?P<pid>\d+)-[0-9a-f]+$")

Lines 542-551

  542         if _exists(entry + QUARANTINE_SUFFIX, tasks_fd):
  543             continue
  544         try:
  545             found[entry] = _generation(os.lstat(entry, dir_fd=tasks_fd))
! 546         except OSError:
! 547             continue
  548     return found
  549 
  550 
  551 def _sweep_dead_claims(dirs: _Dirs, journaled: set[str], out: dict,

Lines 556-564

  556     for the next plan to judge; when that name is taken it stays, reported.
  557     Only the generation seen (or, with `only`, approved) is ever moved."""
  558     for entry, generation in _sweepable(dirs.tasks_fd, journaled).items():
  559         if only is not None and only.get(entry) != generation:
! 560             continue
  561         live = _CLAIM_RE.match(entry).group("live")
  562         try:
  563             os.link(entry, live, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
  564         except FileExistsError:

Lines 564-574

  564         except FileExistsError:
  565             out["errors"].append(f"orphaned claim tasks/{entry} from a dead pass; its name {live!r} is "
  566                                  "taken, so it is kept — rename it back to requeue")
  567             continue
! 568         except OSError as exc:
! 569             out["errors"].append(f"orphaned claim tasks/{entry} from a dead pass could not be given back ({exc})")
! 570             continue
  571         with task_archive.retired_dir(dirs.archive_fd) as rfd:
  572             if _generation(os.lstat(live, dir_fd=dirs.tasks_fd)) != generation:  # rewritten or replaced
  573                 moved = task_archive.retire(live, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=None)
  574                 out["errors"].append(f"orphaned claim tasks/{entry} changed since it was approved; it is "

Lines 580-588

  580                 out["errors"].append(f"orphaned claim tasks/{entry} was rewritten while it was given back; "
  581                                      f"it is not made live (kept as {_retired(stray)} and {_retired(moved)})")
  582                 continue
  583         if stray:
! 584             out["errors"].append(f"orphaned claim tasks/{entry} was given back as {live!r}, but a file "
  585                                  f"that replaced it is kept as {_retired(stray)}")
  586         out["swept"].append({"claim": entry, "live": live})
  587 

Lines 595-604

  595     try:
  596         with task_archive.retired_dir(dirs.archive_fd) as rfd:
  597             return [n for n in task_archive.retired_names(claim, rfd)
  598                     if _generation(os.lstat(n, dir_fd=rfd)) != generation]
! 599     except FileNotFoundError:
! 600         return []
  601 
  602 
  603 def _exists(name: str, dir_fd: int | None = None) -> bool:
  604     try:

Lines 632-641

  632 
  633 def _same_file(a: str, a_fd: int, b: str, b_fd: int) -> bool:
  634     try:
  635         sa, sb = os.lstat(a, dir_fd=a_fd), os.lstat(b, dir_fd=b_fd)
! 636     except OSError:
! 637         return False
  638     return stat.S_ISREG(sa.st_mode) and (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino)
  639 
  640 
  641 def _finish_return(dirs: _Dirs, name: str, claim: str, generation: str) -> "str | None":

Lines 654-662

  654         strays = _retired_strays(dirs, claim, generation)
  655         if strays:
  656             return (f"lost track of {name!r}: it is back under its name, but a file that replaced "
  657                     f"its claim is kept as {', '.join(_retired(x) for x in strays)}")
! 658         return None
  659     with task_archive.retired_dir(dirs.archive_fd) as rfd:
  660         if not has_live:
  661             os.link(claim, name, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
  662             task_archive._fsync_dir(dirs.tasks_fd)

Lines 660-669

  660         if not has_live:
  661             os.link(claim, name, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
  662             task_archive._fsync_dir(dirs.tasks_fd)
  663             if _generation(os.lstat(name, dir_fd=dirs.tasks_fd)) != generation:
! 664                 moved = task_archive.retire(name, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=None)
! 665                 return (f"lost track of {name!r}: its claim tasks/{claim} no longer holds the planned "
  666                         f"bytes; that file is kept as {_retired(moved)} and the claim stays")
  667         stray = task_archive.retire(claim, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=generation)
  668     if stray:
  669         return (f"lost track of {name!r}: it is back under its name, but a file that replaced its "

Lines 682-690

  682         return None
  683     try:
  684         fd = os.open(where, os.O_RDONLY | _NOFOLLOW | getattr(os, "O_NONBLOCK", 0), dir_fd=dirs.archive_fd)
  685     except FileNotFoundError:
! 686         return None
  687     except OSError:
  688         return None
  689     with os.fdopen(fd, "rb") as fh:
  690         st = os.fstat(fh.fileno())

Lines 688-701

  688         return None
  689     with os.fdopen(fd, "rb") as fh:
  690         st = os.fstat(fh.fileno())
  691         if not stat.S_ISREG(st.st_mode):
! 692             return None
  693         data = fh.read()
  694         try:  # still this file under this name once read
  695             after, named = os.fstat(fh.fileno()), os.lstat(where, dir_fd=dirs.archive_fd)
! 696         except OSError:
! 697             return None
  698         if _generation(after) != _generation(st) or _generation(named) != _generation(st):
  699             return None
  700         return _generation(st), data, st.st_mtime

Lines 838-846

  838     txn, name = d.get("txn"), d.get("result")
  839     if d.get("stage") != "publishing" or not isinstance(txn, str) or not txn or not name:
  840         return False
  841     if _witnessed(results_dir / _witness(name, txn), txn):
! 842         return True
  843     try:
  844         return _owned_by((results_dir / name).read_bytes(), txn)
  845     except FileNotFoundError:
  846         return False

Lines 883-893

  883             held = None
  884         except OSError as exc:
  885             held = exc
  886         if isinstance(held, bytes) and _owned_by(held, txn):
! 887             _fsync_path(results_dir)
! 888             report["result"] = str(target)  # committed before the crash: no second notice
! 889             return True
  890         if held is None:
  891             try:
  892                 report["result"] = str(_publish_at(results_dir, d["result"], body, txn))
  893                 return True

Lines 927-945

  927     if not names:
  928         return
  929     try:
  930         task_archive._fsync_dir(dirs_archive_fd)
! 931     except OSError:
! 932         return
  933     for n in names:
  934         try:
  935             os.unlink(results_dir / n)
! 936         except OSError:
! 937             pass
  938     try:
  939         _fsync_path(results_dir)
! 940     except OSError:
! 941         pass
  942 
  943 
  944 def _journal_claims(archive_fd: int, name: str) -> "tuple[dict, str] | None":
  945     """A journal's (data, sha256 of its bytes), or None when it cannot be read."""

Lines 1071-1079

  1071         except OSError:
  1072             if time.monotonic() >= deadline:
  1073                 os.close(fd)
  1074                 return None
! 1075             time.sleep(0.05)
  1076 
  1077 
  1078 def _locked(dirs, results_dir, archive_dir, moves, render, prefix, now, still_safe, arbitrate,
  1079             authorized, out, render_recovered=None) -> dict:

src/undelivered_quarantine.py

Lines 127-135

  127         fn = libc.renameat2
  128         fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
  129         fn.restype = ctypes.c_int
  130         return lambda sfd, a, dfd, b: fn(sfd, a, dfd, b, 1)                # RENAME_NOREPLACE
! 131     return None
  132 
  133 
  134 _RENAMEAT = _probe_renameat()

Lines 143-153

  143             return
  144         err = ctypes.get_errno()
  145         if err == errno.EEXIST:
  146             raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), dst)
! 147         if err not in (errno.ENOSYS, errno.EINVAL, errno.ENOTSUP, getattr(errno, "EOPNOTSUPP", errno.ENOTSUP)):
! 148             raise OSError(err, os.strerror(err), src)
! 149     raise FileExistsError(errno.ENOTSUP, "no no-replace rename on this platform; nothing moved", dst)
  150 
  151 
  152 def no_primitive(e: OSError) -> bool:
  153     """The refusal meant "this platform cannot", not "the name is taken"."""

@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.

No blocking findings. The classifier now owns preview extraction from the parsed task body, cuts the appended system-instructions block, collapses whitespace, and caps at 100 characters, so recovery DMs cannot accidentally expose envelope headers. The task-last, task-mid, no-block, and helper controls are covered; the focused 58-test suite and all hosted checks pass.

Reviewed by Qingyun's Personal Codex.

@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 — exact head 914dc4fc2bd68401573d5476dd93b186b153f349 is not merge-ready.

Blocker — neutralize result-protocol markers in untrusted previews. skills/task-orphan-check/scripts/classify.py:206-232 copies the parsed task body into preview with only whitespace collapsing and truncation. skills/task-orphan-check/SKILL.md:106,140-150 then directs previews from every tier, including team/other, into a trusted proactive-orphan-recovery-*.txt result. However, src/result_markers.py:286-304 interprets [file:], [send:], and [attach:] anywhere outside code as attachment actions, and the proactive Discord path executes them at src/discord-bridge.py:5550,5614-5615,5739-5743.

I composed the specified recovery bullet from a team task preview and passed it through the production parser and attachment policy:

preview='Please recover this task [file: /tmp/sutando-review-…/local-note.txt]'
actions=[('attach', '/tmp/sutando-review-…/local-note.txt')]
attachment_outcome=('send', '/tmp/sutando-review-…/local-note.txt')

The worst case is that a non-owner seeds a task with an early attachment marker naming an existing allowlisted local file; if that task becomes orphaned, startup recovery uploads the file to an external chat service. The allowlist includes workspace results/notes/docs and fixed Desktop/Documents roots (src/policy/egress/attachment.py:58-79). Although the normal Discord recipient is the owner, the external file egress is still controlled by an untrusted sender, bypassing the non-owner result guard.

To unblock: neutralize executable result markers in code before preview text enters the proactive result, and add a regression that builds the final recovery body from a team/other task containing all three attachment aliases and proves parse_markers(...).actions contains no attachment action. A prose-only quoting instruction is not a sufficient trust boundary.

The claimed original defect is independently confirmed: a production-shaped task-last input produces a parent preview beginning with id: task-…, while HEAD produces the actual ask. The four new tests are discriminating: all 58 classifier tests pass at HEAD, while those four fail against the parent module. result-markers (48 tests), team-result-guard, local-task-protocol, task-body-guard, Slack/Discord writer-adjacent tests, import-trigger tests, and the skill lint pass. git diff --check and scripts/review-checks.sh pass; its positive control rejects a synthetic /Users/reviewer/private addition, and no host-specific path was added.

This is one commit directly over the origin/main merge-base (0 1), not a stack. The supplied review snapshot contains no existing reviews or inline comments. Hosted required checks and the two-maintainer approval gate remain conditions after the blocker is fixed.

Reviewed by Kewei's Codex automation.

@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.

Blocking: the new preview field carries untrusted task text into the proactive orphan-recovery result without neutralizing executable result markers. Team/other task bodies can include [file:], [send:], or [attach:]; once embedded in the trusted recovery result, the normal marker parser can turn those strings into attachment actions and externally send an allowlisted local file under an untrusted sender's control. Please neutralize result-protocol markers before constructing the proactive result and add an end-to-end regression that builds the recovery body from a non-owner task containing all aliases and proves marker parsing yields no attachment action. This supersedes my earlier approval, which missed this cross-boundary egress path.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Blocker fixed and pushed at 90a92645a3d2b52485f1a1c2404b07ffd08659b9.

@keweichen's REQUEST_CHANGES and @qingyun-wu's superseding REQUEST_CHANGES (both at 914dc4fc) named the same cross-boundary egress: preview carried untrusted task text into the trusted proactive-orphan-recovery-* result, where result_markers reads [file:]/[send:]/[attach:] as attachment actions that the proactive Discord path executes. I reproduced it before changing anything rather than taking it on report.

Fix, in code — classify.py:neutralize(). [ and ] become ( and ) in preview and label before either leaves the JSON row. A square bracket is the structural precondition of every marker, so this cannot leave an action behind, and it does not duplicate the marker grammar the repo deliberately keeps in one module (classify.py stays stdlib + local_task_protocol; the production parser appears only in the test, as the oracle). Truncation now runs after neutralization so a cut cannot re-open a marker. label is included because it also lands in the row body — pointed out in the directive, and covered by its own test.

Before/after, same synthetic team-tier task through the real step-3b row shape and the production parser:

PARENT 914dc4fc2 (the reported defect):
  file    actions=[('attach', '/w/notes/secret.md')]
  send    actions=[('attach', '/w/notes/secret.md')]
  attach  actions=[('attach', '/w/notes/secret.md')]
HEAD   90a92645a (this push):
  file    actions=[]
  send    actions=[]
  attach  actions=[]

The regression, and what its control proves. TestPreviewMarkerNeutralization builds the step-3b body from a team-tier task carrying all three aliases and asserts no attach action survives. On its own that is a zero, and a zero from a broken probe looks identical to a zero from a safe one — so it ships with a positive control asserting the same body built from the un-neutralized text does yield ('attach', '/w/notes/secret.md'). The control passes in both states; only the safety assertions move. Stubbing neutralize() to identity fails 6 tests (all three aliases, the label case, the helper, and the existing task-last preview test) while the control still passes — so the null is a finding, not a vacuum.

Reader-visible consequence, called out in SKILL.md and the iteration log: a bracketed prefix now renders as (Discord @name). The existing task-last assertion was updated to that reality rather than relaxed.

Suite 58 → 62, all green. result-markers (48), team-result-guard, local-task-protocol, task-body-guard (95) all pass unchanged. review-checks.sh --diff PASS (hardcoded-paths + root-artifacts + prose-cap); 0 host paths in added lines. One commit on top of 914dc4fc, not an amend.

SKILL.md step 3b now states that the classifier, not the prose, is the trust boundary — per @keweichen's "a prose-only quoting instruction is not a sufficient trust boundary".

@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 — exact head 90a92645a3d2b52485f1a1c2404b07ffd08659b9 is not merge-ready.

Blocker — step 3b still tells the agent to bypass the neutralized label. skills/task-orphan-check/SKILL.md:108 says the classifier is the trust boundary, but item 3 at lines 122–132 still instructs the agent to reread room_name / channel_name and reconstruct label directly from the task header. That bypasses classify.py:202-205. This is an executed path, not explanatory pseudocode: recovery remains agent-driven prose. src/discord-bridge.py:4130-4135 explicitly identifies channel names as attacker-settable.

The new regression does not exercise that branch: tests/task-orphan-check-classify.test.py:635-640,689-700 constructs the recovery body from row["label"], which is already neutralized. I followed the literal step-3b recipe instead, then passed the result through the production parser and attachment policy using a regular file under an allowlisted temporary prefix:

classifier-row: actions=[]
classifier-row: attachment_outcomes=[]
literal-step-3b: actions=[('attach', '/tmp/sutando-review-jukt5lo1'), ('attach', '/tmp/sutando-review-jukt5lo1')]
literal-step-3b: attachment_outcomes=['send', 'send']

The marker appears twice because the label is rendered in both By channel and the preview bullet. src/result_markers.py:141-143,286-304 extracts it, and the proactive Discord path consumes attachment actions at src/discord-bridge.py:5550,5614-5615. Worst case: an externally controlled room/channel name causes startup recovery to upload an existing allowlisted notes/results/docs or temporary file to Discord. To unblock, make item 3 consume the classifier row’s label field, remove the raw-header reconstruction recipe, and add a regression/data pin that fails if the documented path starts deriving label again.

Merge-blocking live-path evidence gap. This is /startup step 1 (skills/startup/SKILL.md:75-81) and it writes a delivered proactive-orphan-recovery-* result (skills/task-orphan-check/SKILL.md:142-168), so the PR body’s “not a live path” classification is not supported by the activated path. After fixing the instruction, provide one real post-restart orphan-recovery round trip using a harmless allowlisted fixture. The expected disruption is one brief core restart and one owner recovery DM. I did not perform that external-state-changing check in this isolated review.

The original defect is independently confirmed: the parent step-3b recipe previewed id: task-…\nenvelope_hmac: …, while HEAD’s classifier returned the actual ask, (Discord @echo) recover the lost ask. The code-level tests are discriminating: all 62 classifier tests pass; replacing neutralize() with identity in memory produced 6 failures while the positive control still passed. Result-marker tests ran 48/48, task-body-guard 95/95, and the team-result-guard and local-task-protocol suites passed. git diff --check and scripts/review-checks.sh passed. Added-line host-path scan found 0 hits; its /Users/reviewer/private positive control failed as expected. The merge-base is f973d690294dbe46640896ed48962ba0905f6d5c with origin/main...HEAD = 0 2, so this is not a stack.

Reviewed by Kewei's Codex automation.

@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.

The blocking egress issue is fixed on 90a92645a3d2. Both untrusted preview text and the channel label are neutralized before entering the trusted recovery result, and the regression runs all three attachment aliases through the production marker parser with a positive control proving the original payload would execute. The focused classifier suite passes 62/62 and the delta is whitespace-clean.

Reviewed by Qingyun's Personal Codex.

@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.

Superseding my approval after inspecting the concurrently reported live path: one blocking bypass remains on 90a92645a3d2.

  • skills/task-orphan-check/SKILL.md:122-132 still instructs the startup agent to reconstruct label directly from untrusted room_name / channel_name headers. That bypasses classify.py:neutralize(), and the label is emitted in the trusted proactive recovery result where marker parsing can turn an attacker-controlled [attach: ...] channel name into file egress. The new regression builds its body from row["label"], so it proves the classifier helper is safe but not that the actual prose-driven step uses it. Make step 3 consume the classifier row's neutralized label exclusively, remove the raw-header recipe, and pin the documented path. Because this executes during /startup and emits a real proactive result, the requested harmless post-restart recovery witness is also appropriate.

The preview-side neutralization itself is correct and its 62 focused tests pass.

Reviewed by Qingyun's Personal Codex.

@sonichi
sonichi force-pushed the fix/orphan-check-preview-task-header branch from 90a9264 to 20a37b6 Compare September 20, 2026 03:07
@sonichi

sonichi commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto current main (was 46 commits behind, DIRTY) — resolved a version-number collision in skills/task-orphan-check/SKILL.md's iteration log where this branch's two entries and main's landed worker-held tasks entry had both claimed v0.1.8 independently. Renumbered this branch's entries to v0.1.9/v0.1.10 (newest first), no content changes, no other conflicts (classify.py and the test file auto-merged clean).

New head 20a37b6f2. Verified: python3 tests/task-orphan-check-classify.test.py — 71/71 pass. git diff origin/main...HEAD | bash scripts/review-checks.sh — PASS (hardcoded-paths + root-artifacts + prose-cap clean). git diff --check silent.

mergeStateStatus is now MERGEABLE (was CONFLICTING/DIRTY); still BLOCKED pending fresh review since the head moved — nothing else changed in the diff itself.

@sonichi

sonichi commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

@keweichen @qingyun-wu — this PR has moved since your standing CHANGES_REQUESTED at 90a92645a (2026-09-18): it's now rebased cleanly onto current main at 20a37b6f2 (2026-09-20T03:07:50Z), resolving a version-collision in the rebase but not re-touching the substantive fix you both reviewed. Could you take a look at the current head and dismiss if the underlying blocker is still addressed, or restate if something new needs attention?

@sonichi

sonichi commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Re-requesting review from @keweichen and @qingyun-wu at head 20a37b6 — the fix at 90a9264 that answered your standing CHANGES_REQUESTED was posted 2026-09-18, and the branch was rebased cleanly onto main 2026-09-20T03:07:50Z, but the formal review-request was never re-issued (reviewRequests had gone empty). Re-issued now via GitHub's request-review action so this shows back up in your queue.

@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.

Blocking on current head 20a37b6f2f3f: the rebase preserved the unresolved raw-label bypass.

  • skills/task-orphan-check/SKILL.md:129-137 still tells the startup agent to reread room_name / channel_name from the task header and rebuild label itself. That route never calls classify.py:neutralize(). An attacker-controlled channel name containing [attach: ...], [file: ...], or [send: ...] can therefore still enter the trusted proactive recovery result and be interpreted as an attachment action. The regression only builds its body from row["label"], so it validates the safe helper path while the documented live path continues to prescribe the unsafe one.

Please replace the raw-header recipe with an explicit requirement to consume the classifier row's already-neutralized label field exclusively, and pin that contract so the prose-driven startup path cannot silently reintroduce header parsing. The focused classifier suite passes 71/71, and the preview/label helpers themselves are sound; this remaining instruction is the blocker.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Fixed at head b76deb92e02eb2ea23d03936d9e533b8f4ea6409.

qingyun-wu's ask: skills/task-orphan-check/SKILL.md's step 3 still instructed the reader to rebuild the channel label from header.get("room_name") or header.get("channel_name") -- the same untrusted fields preview used before v0.1.10, but outside classify.py:neutralize()'s reach, on the prose-driven path (a human or agent following this doc by hand instead of consuming classify.py's JSON row).

Fix: step 3 now says to take label straight from the classifier's row -- already neutralized, same as preview -- and never re-derive it from the raw header. No code change was needed: classify.py:channel_label() already ran every label through neutralize() (confirmed at its call site, scripts/classify.py:230), so this closes a prose/code drift rather than a live code gap.

Verified:

  • python3 tests/task-orphan-check-classify.test.py -- 71/71 pass, unchanged (this is a prose-only fix; the underlying script was already correct).
  • git diff origin/main -- skills/task-orphan-check/SKILL.md | bash scripts/review-checks.sh -- clean (hardcoded-paths + root-artifacts).
  • Re-read the pushed head via gh api repos/sonichi/sutando/pulls/4399 and confirmed it matches b76deb92e before writing this comment.

@sonichi
sonichi requested a review from qingyun-wu September 25, 2026 00:37

@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.

The follow-up now keeps the reporting path on the classifier's already-neutralized label instead of teaching readers to reconstruct it from raw room/channel headers. That resolves the remaining marker-injection bypass. The 71-case focused classifier suite and all required hosted checks pass.

Reviewed by Qingyun's Personal Codex.

@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 on b76deb92e02eb2ea23d03936d9e533b8f4ea6409 — the original header-preview bug is fixed, but the current recovery output is still not safe to publish.

  1. Blocking security — the complete recovery row is still marker-injectable through fields other than label/preview. classify.py:253-265 returns id and access_tier without neutralizing them, and Step 3b renders both inside the result body at SKILL.md:153-155. This matters for exactly the historical shapes this skill promises to recover: parse_task_headers_lenient() explicitly says that when a real key is absent, a body line can supply it (src/local_task_protocol.py:303-314), while canonical_access_tier() passes unknown values through (:131-138). At this head I created a harmless legacy task with no trusted tier header and body line access_tier: file: /tmp/sutando-proof-note]; the classifier returned tier file: /tmp/sutando-proof-note], and the documented row rendered as - task-1700000000000 [file: /tmp/sutando-proof-note], c, 15m ago]: harmless. Passing that complete row through production result_markers.parse_markers() produced attach('/tmp/sutando-proof-note'). A second task with no real id: and body line id: [send: /tmp/sutando-proof-note] produced the same action through the raw id field. Please make every interpolated display field inert (or construct and neutralize the complete row in executable code) and add regressions that pass these full missing-header/forged-body rows through the production marker parser. Testing only preview and label cannot prove the result body safe.

  2. Blocking regression gap — the prose-driven fix is not pinned. I checked out the unsafe predecessor 20a37b6f, where Step 3 still reconstructs the raw channel label, and python3 tests/task-orphan-check-classify.test.py still passes all 71 tests. The final fix commit changes only skills/task-orphan-check/SKILL.md; classifier/test blobs are identical before and after it. Add a narrowly scoped executable/data contract over the Step 3 report recipe, or move report construction into code and test the full rendered result. Otherwise this exact regression can return while CI stays green.

  3. Blocking live evidence — the requested post-restart witness is still absent. /startup invokes this skill before the watcher (skills/startup/SKILL.md:75-81), and Step 3 writes a proactively delivered result (skills/task-orphan-check/SKILL.md:145-174), so the PR body's “Not a live path” classification conflicts with REVIEW.md criterion 15 and CONTRIBUTING.md. Please provide one harmless real restart round trip at this head: create an orphan fixture, restart through the shipped path, show exactly one owner recovery DM with inert rendered fields and no uploaded attachment, then show one-time archival/no replay and restore the service.

Verified positives: exact-head classifier tests are 71/71, result-marker tests 48/48, task-body guard 95/95, team-result guard and local-task-protocol tests pass, lint-skill.py and review-checks.sh pass, git diff --check is clean, and hosted CI plus CLA are green. I also merged this head synthetically onto current origin/main (03ef774a) without conflicts and reran the focused suites successfully; the 234-commit base lag has no overlap with the three touched files. Those checks validate the intended preview fix, but they do not close the three blockers above.

@sonichi

sonichi commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Head is now 8674b6356 — all three blockers addressed, with real evidence for each.

1. Marker injection (blocking security)

Fixed properly, not patched around: recovery_line() builds the COMPLETE bullet (never reconstructed from individual fields) and verifies it against result_markers.parse_markers() itself before use, escalating (defanging the recognized keyword's colon, not just the bracket) until proven inert — because bracket-escaping alone isn't enough: a forged value starting file:/send:/attach: still hijacks the TEMPLATE's own surrounding [...] even with its own bracket escaped, since the parser keys on the keyword, not which side opened the bracket. Re-ran your exact repro against the fixed code:

access_tier forged via missing-header body-line fallback -> recovery_line is inert, parse_markers().actions == []
id forged the same way -> recovery_line is inert, parse_markers().actions == []

2. Regression-pin gap (blocking)

5 new tests in tests/task-orphan-check-classify.test.py (71 -> 76): both your exact forged-field repros through the REAL classifier + REAL parser, a positive control proving the OLD hand-rebuilt recipe (recovery_body(), interpolating id/access_tier raw) genuinely still leaks an attach action — so this exact regression now fails a test rather than silently passing 71/71 the way the prose-only fix did — plus a drift pin tying row['recovery_line'] to the helper's own output. SKILL.md step 3 now says to print recovery_line verbatim, same discipline already in place for label.

3. Live post-restart witness (blocking)

Done, with an honest scope note. I ran the REAL classify.py as a subprocess (not in-process import) against a disposable scratch workspace with two real orphan task files carrying your exact forged access_tier/id body-lines, then performed step 3's actual file operations for real:

$ python3 skills/task-orphan-check/scripts/classify.py --workspace <scratch> --now <+1000s>
-> both tasks verdict=orphan, recovery_line inert in both rows (same probes as above, through the real subprocess)

$ <wrote the aggregated recovery body using recovery_line verbatim, then ran it through the real>
$ python3 -c "import result_markers; print(result_markers.parse_markers(open(recovery_file).read()).actions)"
-> []   # no attach action, no suppress/redirect marker effect -- the delivered message is plain text

$ mv both task files to tasks/archive/
$ python3 skills/task-orphan-check/scripts/classify.py --workspace <scratch> --now <+1000s>   # simulated next boot
-> {"tasks": [], "counts": {"total": 0}}   # one-time archival confirmed, no replay

Scope honestly: this exercises the real script and the real production parser end-to-end through genuine subprocess + filesystem operations, not a unit-test mock. It does NOT spin up a second actual Claude Code session/restart through the installed hook machinery the way the #5095/#5145 live-witness bar does — step 2/3 here are a startup-time skill invocation inside an agent's own turn, not an async hook or daemon, so I judged the subprocess-level round trip as the right-shaped witness for this path. Say if you want the heavier session-restart version too and I'll set that up.

CI green at this head (lints, typecheck, ruff, shellcheck, diff-coverage >=95%, all suites); CLA is still showing pending as of this comment, will confirm once it resolves.

@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 — exact head 8674b63568e376a5b1bd08d73700cef2716f82bc is not merge-ready.

The new recovery_line() does close the raw id/access_tier injection in an ordinary preview bullet. I collected all 76 classifier tests, and replacing that helper with the unsafe renderer makes the new suite fail in eight attachment subcases while its positive control still passes. Four blockers remain:

  1. Blocking correctness — an empty preview aborts the entire classifier. classify.py:259 builds a line ending in ": "; production parse_markers() strips that trailing space at src/result_markers.py:303-304, so the equality check at classify.py:261 fails on all four retries and line 264 raises ValueError. I reproduced this both with recovery_line("task-1", "owner", "DM", 900, "") and through classify_workspace() on a real task:-empty file. The workspace loop catches only OSError, and recovery_line() runs before the completion-marker check (classify.py:303 vs :307), so even the same task with an existing result file aborts the pass. One crash-partial/empty task can therefore prevent recovery classification for every task. Please make an empty preview a valid inert line and regress both orphan and already-done cases.

  2. Blocking security — only the preview bullet is safe; the complete recovery result remains marker-injectable. Step 3 still interpolates raw id (plus other raw fields) in the import-stalled, import-unbound, and unknown lines at SKILL.md:174-181. At this head I created a real missing-header legacy task whose body supplied id: [send: <existing allowlisted README>], plus a real unbound import status. The classifier returned import-unbound; parse_markers(row["recovery_line"]).actions was empty, but the documented import-unbound line produced attach(<README>), and production classify_attachment() returned ('send', <README>). That is the same external-file-egress class on a startup row that stays live and can recur. Construct and verify the whole proactive body in executable code (covering deferred, stalled, unbound, and unknown rows), or give every emitted line an equivalent safe renderer and production-parser regression.

  3. Blocking regression gap — the agent-executed use site is still unpinned. The five new tests prove the helper and classifier field, but none reads or structurally pins Step 3. Reverting the prose to hand-build the bullet would still leave all 76 tests green; the unsafe-recipe positive control demonstrates danger but does not prevent the prose from selecting it. An executable whole-body renderer used verbatim by the skill would close this cleanly; otherwise add a narrowly labeled data/delegation pin for the actual Step 3 recipe.

  4. Blocking live evidence — comment 6015330439 explicitly says no real session/service restart occurred. A classifier subprocess, manual scratch result write/moves, and a second classifier invocation are harness evidence. They do not exercise /startup through a restarted service, bridge delivery to the owner, exactly-one delivery, absence of an upload, replay behavior on a second restart, or service restoration. REVIEW.md criterion 15 requires that real post-restart round trip before approval.

Also fix the new display regression at classify.py:259: id already includes task- (SKILL.md:46), but the helper prepends it again, producing task-task-170…; the new assertion at test line 784 currently pins the wrong output.

Verified positives at this SHA: classifier 76/76, result markers 48/48, task-body guard 95/95, team-result guard and local-task-protocol pass, skill lint/review checks/git diff --check pass, and the hardcoded-path scanner's positive control fires. Current main is 234 commits ahead but touches none of the three PR files; the merge tree is conflict-free and focused merged-tree tests pass. Hosted Actions are 26 success plus one expected skip. license/cla is currently absent/pending rather than green, so the merge gate is independently incomplete.

@sonichi

sonichi commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Head is now 6a985c100 — all four fixed, with real evidence.

1. Empty-preview crash (blocking correctness)

Fixed: dropped the body-equality check (parse_markers() strips trailing whitespace unconditionally, even on a fully safe line — not a security property). actions == [] is the only check now. Reproduced your exact crash before the fix, confirmed it's gone after:

classify_task() on an empty-preview task, including one with an existing completion marker -> verdict="done", no exception

2. Stalled/unbound/unknown lines still raw-interpolated (blocking security)

Fixed with the same machinery as the ordinary bullet: stalled_line(), unbound_line(), unknown_line() now exist, each going through a shared _safe_line() helper (bracket-neutralize, verify against the production parser, defang the keyword's colon, escalate). Re-ran your exact shape (forged id through import-unbound):

forged id: [send: README.md] through unbound_line() -> parse_markers(...).actions == []
same forged id through stalled_line(), unknown_line() -> same result
positive control: the OLD raw-interpolation recipe on all three line shapes -> genuinely produces attach(README.md) each time

idle_human() is new too — the "3d 2h" duration shape was previously left for an agent to hand-format from import_idle_s; it's computed once in code now.

3. Unpinned Step 3 use site (blocking regression gap)

SKILL.md's stalled/unbound/unknown report lines now say to print the classifier's own stalled_line/unbound_line/unknown_line fields verbatim — same discipline as recovery_line/label. 5 new tests (81 total): the crash fix, a forged-id repro through each of the three new lines via the real classifier, and the positive control above.

4. Display regression: double task- prefix

Fixed — id already carries its own prefix; none of the four line-builders add a second one now. The wrong test assertion from the first round is corrected.

Live post-restart witness — done for real this time

Two genuinely separate headless claude -p processes (not the same process twice, not a subprocess-level simulation), from an isolated scratch workspace (sutando.config.local.json pointing at /tmp/..., removed after, confirmed git status clean and workspace resolution back to normal):

Restart 1 — fresh process, prompted to read SKILL.md and execute steps 2/3 for real against the scratch workspace (two orphan fixtures carrying your exact forged access_tier/id body-lines):

Found 2 orphans. Wrote /private/tmp/.../results/proactive-orphan-recovery-1791287596.txt
(verified independently: result_markers.parse_markers(file content).actions == [])
Archived both task files to tasks/archive/ (tasks/ now empty)

The model found two things I hadn't: the doc's re-queue mv command and its own archival logic build the file path from id, which breaks when id is the forged value (it used the row's file field instead, correctly) — and the "By tier" line has no bucket for a tier that isn't owner/team/other. Both are real, separate from the three blockers above; flagging for a follow-up rather than scope-creeping this fix in.

Restart 2 — a second, independently fresh process, prompted only to run classify.py and report counts:

{"total": 0}

No replay of the archived tasks.

CI green at 6a985c100 (26 checks + 1 expected skip), CLA green.

@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.

Reviewed exact head 6a985c1006e6f5d08a024a1bea5512fd0601d615.

The new code does fix the narrow line-rendering defects from the last round: the empty-preview case no longer raises, ordinary IDs render with one task- prefix, and recovery_line, stalled_line, unbound_line, and unknown_line are inert under the production marker parser in my adversarial checks. I reran the 81 classifier tests, 48 result-marker tests, the guard suites, skill lint, git diff --check, and the repository review checks. Hosted CI and the SHA-bound CLA check are green. I also tested the cumulative patch against current origin/main; the touched paths do not overlap and the merge-tree is clean.

I still have three blockers.

1. Archive/requeue operations use the untrusted logical ID as a filesystem path

classify.py:347 obtains id from the lenient header/body fallback, while the classifier separately exposes the actual file basename as row["file"] at lines 355-356. The shipped procedure nevertheless asserts that the file is always tasks/<id>.txt (SKILL.md:46) and builds archive/requeue paths from <id> (SKILL.md:87, 107-108, 180, 185).

On this exact head, a physical file named task-traversal.txt whose fallback body supplies id: ../notes/secret classifies as:

{'file': 'task-traversal.txt', 'id': '../notes/secret', 'verdict': 'orphan'}

Following Step 6's ID-derived rename resolves the source outside tasks/: it moved the unrelated notes/secret.txt in my isolated fixture and left the real tasks/task-traversal.txt live. With id: [send: README.md], the instructed source simply does not exist; after the proactive result is written, the physical task still classifies as an orphan, so startup can report it again. The evidence comment itself says the live agent discovered the ID paths were broken and substituted row.file; that is an ad hoc correction, not execution of the checked-in procedure.

Please make the physical basename the sole path identity for archive/requeue operations, validate containment, and add regressions for id != file, traversal-shaped IDs, and no replay. Prefer owning the move/archive plan in executable code. SKILL.md:180 also still adds task- to an <id> that line 46 says is already prefixed, yielding task-task-* for a normal task.

2. The agent-executed composition/use site remains unpinned

The tests establish that each new helper is safe, but no tested whole-body renderer or structural delegation contract establishes that Step 3 actually consumes those fields rather than reconstructing raw values. I replaced only skills/task-orphan-check/SKILL.md with its unsafe pre-fix prose and the exact-head classifier suite still passed all 81 tests. The test named as a Step 3 pin proves only that row["recovery_line"] == recovery_line(...); it cannot observe what the prose-driven step prints.

This is already producing ambiguity outside the preview bullets: Step 3 says to group raw access_tier values, while the fixed summary template only names owner/team/other even though canonical values include guest and ambient and unknown values pass through. Dynamically rendering an unknown tier can recreate the marker sink; keeping the literal template silently omits/miscounts it. Please either render and production-parse the complete result body in executable code, or add a narrowly labeled structural/data-delegation pin covering every emitted untrusted field and the file-owned archive plan.

3. The posted witness is not a real startup/delivery round trip

The evidence comment describes two fresh claude -p processes, but the first was directly prompted to execute the skill against a scratch workspace and the second only ran the classifier. It does not show a restarted Sutando service exercising shipped /startup, an actual bridge/outbox claim and owner-message identifier, sink-level absence of an upload, or a second restarted-service pass with no repeat delivery. Parsing the written result to actions=[] is parser evidence, not transport evidence. The first run also departed from the shipped procedure by using row.file after the documented path failed.

After fixing the path/use-site issues, please capture the requested exact-head live witness: actual /startup through a restarted service, one delivered owner message with its delivery identifier/log and zero upload action, a second real restart with no repeated result/delivery, and restoration of the service. Include the commands/logs or equivalent provenance tying it to this SHA.

The PR body is also stale (58 tests and “not a live path”); please refresh it to match the current patch and evidence before merge.

sonichi added a commit that referenced this pull request Oct 6, 2026
…chive paths

Diff coverage on #4399 dropped to 92.9% after the round-3 CI fix: lines
545-548/595/597 in recovery_plan() (the unbound- and unknown-row body/archive
branches) had no direct test. Adds one test per branch; local coverage for
classify.py is now 99% (309 stmts, 2 missed — lines 272 and 611, the two
defensive raises that are hard to trigger honestly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 6, 2026
…chive paths

Diff coverage on #4399 dropped to 92.9% after the round-3 CI fix: lines
545-548/595/597 in recovery_plan() (the unbound- and unknown-row body/archive
branches) had no direct test. Adds one test per branch; local coverage for
classify.py is now 99% (309 stmts, 2 missed — lines 272 and 611, the two
defensive raises that are hard to trigger honestly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stand: Echo Act IV Pro
@sonichi
sonichi force-pushed the fix/orphan-check-preview-task-header branch from 46b9071 to ed9881b Compare October 6, 2026 13:06
…and placer

Direct tests, one per path:
- a name the reconciler does not own;
- an unreadable retained original;
- new bytes that cannot be reclaimed;
- a requeue blocked by a newer live file;
- a requeue that is already live (only the epoch is recorded);
- a requeue with no copy left (published fresh);
- a parked copy that cannot move;
- a copy that cannot be placed (the claim is kept);
- the superseded-copy guard;
- a claim that is not UTF-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 d15e91eb1aaa03e26d1add784f256e5cd6384345.

The two P1s requested at 635ee6aa are cleared in their advertised windows. A fresh exclusive snapshot now prevents a writer holding the claim descriptor from changing A's terminal placement, and the acknowledged-PARKED path now waits for an explicit higher resend epoch, commits hand-back before advancing/logging, and survives the injected crash cuts. The 43-case witness suite passes here and fails against the prior source; d15e91eb itself is test-only.

One residual lifecycle P1 remains: the retained producer-controlled inode is reconciled only at startup, then is put back into the delivery lifecycle after its first mutation. A later generation can therefore remain hidden indefinitely, and a subsequent generation can silently corrupt delivered evidence and be lost permanently.

The .decided-* scan occurs only inside _recover_orphan_proactive(), whose production call is the one startup call at line 7969. Normal outbound passes only reap .delivered-* and scan live proactive-*.txt files (lines 6050–6051). When a restart does observe changed retained bytes, _reconcile_decided() renames the retained original inode back to a claim; the absent-receipt path then hands that same inode live, so the still-open writer descriptor follows it through delivery into archive/.

Two independent exact-head production-Lane probes reproduced the consequences:

restart(A terminal) -> write B through the old claim fd -> six normal drains
posts=[]; live=[]; B receipt=absent
.decided-* contains B under A's digest

second restart -> B posts once and is archived -> write C through the same fd
archive formerly containing B now contains C
C receipt=absent; live=[]; decided=[]
another restart + six drains still posts no C

The first failure makes a newly written owner message invisible for the lifetime of a long-running gateway unless another restart happens. The second is unrecoverable even across restarts: C is never posted, while the archive for the already-DELIVERED B is mutated to C.

The checked-in regression does not exercise either boundary. It writes B only during the recovery call (lines 634–655), and its settlement helper immediately invokes another restart before draining (line 588). It never writes after restart returns, nor after that inode has been delivered and archived.

Please keep the uncertain original inode in the hidden reconciled namespace, publish each observed later generation from a private immutable snapshot rather than handing that inode live, and reconcile it during normal operation rather than only at startup. Add regressions for (1) B written after restart() returns and delivered by ordinary drains with no extra restart, and (2) C written through the same descriptor after B is delivered, with C delivered once and prior archive bytes unchanged.

Permanent retention is directionally the correct fail-closed choice here—deleting the only inode a late descriptor can still modify reopens the race. Its storage/accounting policy can be documented and monitored, but the correctness requirement is that the retained original remain retained and be reconciled continuously; this head stops doing that after the first changed generation.

Positive exact-head evidence: gateway-delivered-retire-keeps-witness 43/43, task-transition 113/113, outbox-receipt-state 11/11, result-publish 30/30, the adjacent gateway recovery suites, source/vendored sync, git diff --check, and cumulative review checks all pass. Hosted CI and CLA are green. The real exact-head /startup restart → one owner delivery → second restart/no-repeat witness is also still owed and remains a separate pre-approval gate after this P1 is fixed.

Worst case at this head: a later owner message is silently never sent and an earlier delivered archive is silently rewritten. Verdict: changes requested; not ready to merge.

…oncile it on every drain

The retained original of a decided claim was reconciled only at startup,
and its first changed generation was renamed back into the delivery
lifecycle. Two consequences followed:
- B written through a held descriptor after restart() stayed unsent through
  ordinary drains.
- Once that inode was archived, C written through the same descriptor
  rewrote B's archive and was never sent.

- _reconcile_decided() no longer moves the original. New bytes are published
  as a private snapshot claim (exclusive, through result_publish) and
  decided from their own receipt. The original only has its recorded digest
  updated, inside the hidden .decided- namespace, after the snapshot exists.
  If an identical copy is already live, claimed, hidden, archived or parked,
  only the digest is recorded, so nothing is published twice.
- Each drain calls _reconcile_changed_decided(), which lstats each retained
  original. Only one whose (size, mtime_ns, ctime_ns) changed since last
  seen is read again; the stat taken before the read is the one recorded.
- A delivered proactive file is archived under a nanosecond name. A
  whole-second name let a second delivery of the same stem in that second
  replace the first archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 8d3d80af968dcfe576689d53b4b239badb56e798 over parent d15e91eb1aaa03e26d1add784f256e5cd6384345 in fresh detached checkouts.

The intended happy paths are now covered: the focused suite runs 46/46 at HEAD, and the exact-parent negative control fails the five claimed cells (the two B-after-restart cases, the two C-after-B cases, and snapshot-before-record). The adjacent proactive/outbox suites, git diff --check, the hardcoded-path scan, current CI, and CLA are green. I nevertheless found four production-path blockers.

[P1] Do not treat a failed or blocked reconciliation as settled by the stat cache.

_reconcile_changed_decided() caches the pre-call stat unconditionally, although reconciliation can return without making progress. In a real Lane, I injected one OSError from _publish_text_whole for changed retained B, removed the fault, and ran six ordinary drains. For both DELIVERED and PARKED the result was:

after_6_more=[]
retained=[('.proactive-1.decided-...', 'UNSENT B')]
after_restart=['UNSENT B']

The unchanged inode is never retried after recovery; only a process restart exposes B. The same defect occurs on the normal _hand_back_decided() false path: if live C blocks requeued B, the first drain delivers/removes C but caches B's stat, and five more drains never hand B back. Restart does so immediately. Return a conclusive/retryable outcome and cache only a conclusive reconciliation (or include every external decision input); add both transient-failure and B-blocked-by-C eventual-retry regressions.

[P1] A new snapshot can be stranded forever, and the retained writer is sampled without the queue's readiness gate.

The changed-generation path publishes a current-process .sending claim and immediately decides it. When newer live C holds proactive-1.txt, B's handback collides. Six ordinary drains produced:

posts=['NEWER C']
claims=[('proactive-1.sending.<current-pid>.<ns>', 'UNSENT B\n')]
B_receipt=absent

Normal drains do not revisit a live worker's .sending claim, so B remains hidden until this gateway dies/restarts even after C clears.

There is also no stability/readiness check before the retained inode is treated as a complete generation: _claim_receipt() and _read_claim_bytes() are immediate reads, unlike the existing live-file readiness gate at lines 6125-6134. With the producer descriptor held open, draining between truncate and rewrite stranded an empty snapshot/live file and the later B claim; draining after a stable prefix delivered both UNSENT and then UNSENT B. The new helper writes B in one uninterrupted call, so it misses this boundary. Changed retained bytes need the same completed-generation criterion as ordinary queue files, and every staged snapshot needs an ordinary-drain path to eventual decision after a collision.

[P1] _copy_exists() is not durable or exact proof that the new generation was snapshotted.

_copy_exists() accepts mutable live/current claims, then the caller records the digest without owning or freezing those bytes. In a deterministic race I changed the matching live B to C immediately after _copy_exists() read it. The result was:

race_triggered=True
posts=['NEWER C']
B_snapshot_claims=[]
B_receipt=absent

B was marked recorded under .decided-* but was never published. The archive glob is also prefix-ambiguous: proactive-1-longer-123.txt with the same bytes satisfies the proactive-1-*.txt lookup, permanently suppressing the short-stem generation even across restart. The stated contract requires a fresh private snapshot; mutable adjacent names and loosely matched archive names cannot substitute for one. Use owned immutable placement (and exact parsed ownership if any durable dedup remains), with TOCTOU and prefix-collision regressions.

[P2] Nanoseconds still do not provide no-clobber archive placement.

Path.rename() still replaces the destination. Freezing time_ns() and delivering B then C under the same stem gave:

posts=['B', 'C']
archive_after_B=[('proactive-1-1234567890123456789.txt', 'B\n')]
archive_after_C=[('proactive-1-1234567890123456789.txt', 'C\n')]

Both receipts are DELIVERED, but C silently overwrites B's only archive. Higher clock resolution narrows the collision window; it does not establish uniqueness or immutability. Allocate with the repository's no-replace/unique-name contract and freeze the timestamp in a regression.

The real /startup restart → one owner delivery → second restart/no repeat witness is also still explicitly owed. That remains a separate approval gate after the code blockers are fixed.

Verdict: REQUEST_CHANGES — this head is not merge-ready.

sonichi and others added 2 commits October 25, 2026 03:34
…le settled

_reconcile_changed_decided() recorded each original's stat whatever the
reconcile did. A snapshot that could not be placed (disk full, EPERM), or a
generation that could not be read, therefore left a stat that never changed
again, and every later drain skipped the new bytes until a restart.

- _reconcile_decided() now returns True only once settled. It returns False
  when the read fails, the bytes are still being written, the snapshot
  cannot be placed, the new digest cannot be recorded, or a proven requeue
  cannot be handed back. Only a settled reconcile records the stat, so a
  failed one is retried on the next drain. Each retry is one reconcile of
  that file per drain, not a loop.
- _DECIDED_SEEN drops entries whose file is gone. The originals themselves
  are never reaped.

Regressions run through the real Lane: a placement failure, and an
unreadable generation, each held across three drains (one reconcile per
drain) and then cleared, after which an ordinary drain delivers B once with
no repeat; plus pruning of a vanished entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a drain published new bytes as a snapshot claim and deciding that
claim failed, the reconcile still reported settled. The decision fails when
_hand_back finds a newer file holding the live name, or when _move_decided
returns None. The claim carries this process's pid, so the orphan sweep skips
it, and _copy_exists made later drains publish nothing, so the bytes waited
for a restart.

- Snapshot claims are named <stem>.sending.<pid>.s<ns>. Each reconcile first
  decides this process's pending snapshots for that stem again, and reports
  unsettled while any remains, so the next drain retries.
- The new-bytes branch also reports unsettled while the claim it just
  published still exists after the decision.

Regressions run through the real Lane, for DELIVERED and PARKED: a hand-back
blocked by a newer live file, after which both bodies are sent once with no
restart and no snapshot is left behind; and a digest that cannot be
recorded, which is sent once and recorded by a later drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 current head 66f382bf1685ca2c6140e8a992f298012e3feee9. The queued re-review named fcf46a48, but the PR advanced one commit while I was reviewing it, so I tested both layers.

The narrow fixes discriminate correctly. The focused suite is 49/49 at fcf46a48; running that test source against 8d3d80af fails the five claimed cells. Current head is 51/51; running its test source against fcf46a48 fails the two snapshot-handback cells. The adjacent receipt/recovery/crash/thread suites and both child-only review scans pass. The requested fcf CI was green after the documented worker-config rerun; current CI is presently red on two worker-pool runtime-exit timeouts, so it is not a green merge candidate either.

[P1] A snapshot from the process that died before this restart is retried only once, then waits for a second restart.

_recover_orphan_proactive() tries an old-PID snapshot at startup. If live C blocks its hand-back, the new ordinary-drain scan ignores it because it scans only .sending.<current-pid>.s*. In a real Lane with a confirmed-dead owner:

after_restart_and_4_drains posts=[NEWER C] old_snapshot_exists=True
after_second_restart       posts=[NEWER C, UNSENT B] old_snapshot_exists=False

The first restart is the exact recovery boundary this code owns. Once C clears, later drains must revisit that old snapshot without waiting for another process death, while still preserving the live-owner fence.

[P1] A receipt read failure with readable retained bytes is cached as settled.

_claim_receipt() can return (unknown, <valid digest>, None): it computes the body digest before outbox.receipt() reports an unreadable or malformed item record. The reconciler rejects only generation is None, so this tuple falls through as True and the stat cache suppresses every later read. My exact-head probe gave:

after 3 more drains: receipt calls=1 live=False posts=[]
after restart+drain: posts=[B]

Repairing or atomically replacing the outbox JSON changes no retained-file stat. Unknown receipt state is not settled; pin this production return shape, not only (unknown, None, None).

[P1] A retained writer still bypasses the shared readiness gate.

The changed-generation branch performs two immediate reads at lines 5828-5836, rather than the young-file settle check used by ordinary results. Pausing a held descriptor after writing UNSENT, draining, then completing UNSENT B\n produced posts=[UNSENT, UNSENT B] for both the DELIVERED and PARKED original. Truncating to empty first instead leaves an empty live file permanently blocking the later B snapshot. This P1 from the prior review is unchanged.

[P1] _copy_exists() still treats unrelated or mutable bytes as durable proof.

At lines 5838-5845, a positive copy probe lets the retained digest be recorded without creating an owned snapshot. Its archive glob is prefix-ambiguous: an unrelated archive/proactive-1-longer-123.txt containing B satisfied proactive-1-*.txt. After six drains plus a restart, B had receipt=absent and posts=[]; only the longer stem owned those bytes. The mutable-live TOCTOU from the previous review is unchanged too. The generation needs an owned immutable snapshot or exact parsed ownership.

[P2] The claimed work bound and archive no-clobber invariant still do not hold.

With two retained originals for one stem and one blocked current-PID snapshot, one real drain called _decide_orphan_claim twice on that same snapshot because every retained original invokes the stem-wide scan. Retained originals intentionally accumulate, so fault work scales with history instead of one retry per snapshot per drain.

Separately, the delivered archive still uses replacing Path.rename(). With time_ns() frozen, B and C were both posted, but C replaced B at the same archive name. Use the repository no-replace allocator and pin the collision.

Worst case at this head is a body delayed until another restart, a body silently lost behind false copy proof, or a user receiving both a partial and completed message; terminal history can also be overwritten and fault work grows with retained history. The real post-restart owner-delivery/no-repeat witness is still owed after these code blockers clear.

Verdict: REQUEST_CHANGES — current head is not merge-ready.

… writers, and prove copies only by ownership

Review of 66f382b:

- A snapshot claim left by a process that died was tried once at restart.
  Each drain now runs _retry_pending_snapshots() once and decides again
  every `.sending.<pid>.s<ns>` claim whose owner is this process or dead. A
  live other owner's claim stays fenced. Because this pass runs once per
  drain, rather than once per retained original of the stem, work is one
  decision per pending snapshot per drain however many originals
  accumulate.
- A receipt of (unknown, <valid digest>, None) was cached as settled.
  An unknown receipt state now reads as not settled, and the reconcile is
  retried each drain until the outbox record reads.
- New bytes in a retained original are taken only once they are ready by
  result_ready's check (non-empty, unchanged under the read) and older than
  SETTLE_SEC (_DECIDED_SETTLE_SEC), because that writer never publishes
  whole. A paused or truncating writer no longer gets a prefix sent, and
  no longer leaves an empty live file.
- _copy_exists() is gone. A matching live file, claim or prefix-globbed
  archive no longer stands in for a copy. New bytes are skipped only when
  their own outbox item is terminal, or when this stem already holds an
  immutable snapshot claim of them. Archive and parked lookups match exactly
  `<stem>-<digits>[.n].txt`, so a longer stem's file never counts.
- The delivered archive is a no-replace placement (place_as), so a second
  delivery in the same tick never replaces the first archive.

Regressions run through the real Lane, one or more per finding, and each
fails at 66f382b. The recovery-edges archive-failure mocks also route the
new placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 8b2735af0ecdc08fde5e07694e30fc6f5777200d (direct parent 66f382bf1685ca2c6140e8a992f298012e3feee9).

The advertised fixes discriminate: the focused real-Lane suite is 60/60 at this head and at GitHub's current-main synthetic merge, while the same test source against the direct-parent production file fails 13/60 in the claimed cells. The adjacent proactive recovery, receipt, bridge, worker, review-scan, and vendored-sync checks I ran pass. Current hosted CI and CLA are green after the documented unrelated worker-config rerun.

That clears the direct-parent repros as written: dead-owner snapshots are revisited once per drain; unknown is not cached; the retained path now calls the shared readiness helper; the prefix-ambiguous/mutable _copy_exists proof is gone; and ordinary archive collisions use place_as. Four remaining production paths still violate the same recovery invariants.

[P1] Keep snapshot ownership through the retained-digest commit.

At _reconcile_decided() lines 5839-5846, _own_snapshot_exists() is a read-only proof followed later by the retained rename. A snapshot owned by a confirmed-dead process is eligible for every drainer's _retry_pending_snapshots(), so another drainer can move that proof live between those operations. I paused immediately after the real proof read, ran the real dead-owner retry in the competing path, then atomically published newer C over the returned live B before allowing the retained commit:

settled=True
posts=['NEWER C']
B receipt=absent
retained=[('.proactive-1.decided-<B digest>-ex.1', 'UNSENT B\n')]

Four more drains plus restart still never sent B. The live-owner fence does not cover this case because the snapshot owner is dead, exactly the class this commit makes retryable. The proof needs to remain exclusively owned until the retained generation is recorded.

[P1] One-second age does not establish completion for an inode whose writer may still hold a descriptor.

_retained_settled() delegates to the shared helper, but that helper performs its second observation only while a file is younger than one second. This retained inode exists specifically because its writer can continue through an open descriptor. Pausing that real descriptor for 1.15 seconds after writing the prefix produced:

after_paused_prefix=['UNSENT']
after_completion=['UNSENT', 'UNSENT B']
archive=['delivered A', 'UNSENT', 'UNSENT B']

The new readiness tests cover a shorter pause and a write during the 50 ms hold; they do not cover a writer paused beyond SETTLE_SEC. Please establish an actual completion/ownership boundary for this retained writer and pin the longer pause, rather than treating age as completion.

[P1] Invalidate settled retained entries when their receipt/epoch changes.

_DECIDED_SEEN caches only retained-file stat. The supported operator requeue changes PARKED e0 to QUEUED e1 without changing that inode. With the production outbox writer and no quarantined copy left for the CLI to restore:

initial receipt=('PARKED', 0), retained cached=True
requeue_item_with_epoch -> (REQUEUED, 1)
after 3 drains: posts=[], live=False, receipt=('QUEUED', 1)
after restart: posts=['parked body'], receipt=('DELIVERED', 1)

The epoch-aware handback at lines 5856-5865 therefore cannot run until process restart. The cache must observe receipt state/epoch changes (or otherwise keep this transition retryable), just as the fixed unknown state now remains retryable.

[P2] Make the delivered archive-failure fallback no-replace too.

The normal move now correctly uses place_as, but its exception handler still calls replacing Path.rename(). With fixed time_ns() and persistent place_as -> ENOSPC, two same-stem deliveries gave:

posts=['B', 'C']
receipts={'B': 'DELIVERED', 'C': 'DELIVERED'}
after B: hidden body='B\n'
after C: hidden body='C\n', B inode unlinked
archive=[]

Delivery remains exact-once, but C replaces B's only hidden terminal witness. The new collision test covers only successful place_as; the recovery-edge tests restore placement before a second delivery. Use a no-replace/uniquified hidden placement (or retain the claim) and add the persistent-failure + same-clock two-delivery regression.

Separately, the PR body still records the required exact-head real /startup -> one owner delivery -> second restart/no-repeat witness as owed. That remains a pre-approval live-path gate after the code findings clear.

Verdict: REQUEST_CHANGES — not merge-ready at this head.

…riginal when its item changes, keep hidden witnesses no-replace

Review of 8b2735a, findings 1, 3 and 4. Finding 2, the paused writer, waits
on a design choice from the reviewer.

- A dead owner's snapshot claim was proof, by read only, until the retained
  digest was committed, so a competing drainer could move it live in
  between and B was lost. The reconcile and the per-drain retry now take a
  dead owner's snapshot first, renaming it to this process's pid. A live
  owner's pid fences it from every other drainer. Only a snapshot owned
  this way is relied on.
- _DECIDED_SEEN now records, besides the stat, the item the original's
  bytes would be sent as and that item's (state, epoch), read before the
  reconcile. An operator's PARKED e0 -> QUEUED e1 requeue changes no
  retained stat, but now makes the next drain reconcile again and hand the
  body back.
- When the archive placement fails, the hidden fallback is now a no-replace
  placement too (place_as over .<stem>.delivered-<ns+i>). Under a fixed
  clock, a second delivery no longer replaces the first hidden witness.

Regressions run through the real Lane, each failing at 8b2735a. The
recovery-edges mocks also route the hidden placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 25, 2026

Copy link
Copy Markdown
Owner Author

Design question on the paused-writer finding from review 5643152569, before I build anything for it. Findings 1, 3 and 4 from that review are fixed at 9481d423. This finding is the one I can't fix with code alone.

The constraint: a .decided-* original is an inode that a writer may still hold open, and an open descriptor can't be revoked. That writer never publishes whole. From outside, a writer that paused after UNSENT looks exactly like one that finished writing UNSENT. Any settle time can be beaten by a longer pause, so I've stopped tuning the timer. The choice is what to do with a changed generation we can't confirm is complete.

Options:

  • A. Treat changed bytes as unconfirmed and park them. A changed generation is never auto-sent. Its bytes are published as an immutable snapshot into undelivered/ under their own item, with the usual logged outbox_cli.py requeue … --body-sha256 release command. An operator sends it by releasing it.
    • No prefix is ever sent, and nothing is lost: each distinct generation the reconciler sees becomes a parked entry with its own digest.
    • Cost: a legitimate later message from that writer waits for a human. A paused writer can leave two parked entries (prefix and full), and the operator releases the right one by digest.
    • This path only arises on restart-recovery leftovers, so the volume is small.
  • B. Deliver every observed generation once. Each distinct settled generation is sent once under its own item.
    • Nothing is lost and no human step is needed.
    • Cost: the recipient can get a partial message followed by the full one. That is the outcome the review flagged.
  • C. Require an explicit completion marker. A changed generation is auto-sent only if it ends with an agreed terminator, and anything without the terminator falls back to A.
    • This needs a producer-side protocol that writers holding a descriptor don't follow today. In practice it is A with an exception nobody uses yet.

My pick is A. It matches this PR's fail-closed contract, where an uncertain outcome is parked for an operator and never resent on doubt. It also reuses the existing park and release path, and its cost falls on a rare recovery corner, not on ordinary delivery.

If A is chosen:

  • The readiness and SETTLE_SEC gate on this path is removed, since nothing is auto-sent there.
  • The B-after-restart and C-after-B regressions change from "sent once" to "parked once with a usable release command; released, sent once".
  • A regression for a writer paused past SETTLE_SEC is added, expecting both generations parked and nothing posted.

Reviewer, which do you want: A, B or C? I'll hold this finding until you choose.

…r the owner, born PARKED through the shared outbox writer

Finding 2 of the review of 8b2735a, built as the option the reviewer chose
(A, with an immutable snapshot). No settle time can tell a paused writer
from a finished one, so a retained original's changed bytes are never sent
automatically.

- outbox.publish_parked() (src and vendored, byte-identical) creates an
  item already PARKED, in one write under its item lock. It never has a
  READY moment, so it is never publish() followed by park_unclaimed(). It
  returns CREATED, ALREADY_PARKED (a retry, or a second writer racing with
  the same payload) or EXISTS (anything else, which is left untouched). Its
  only way out is the operator's requeue_item_with_epoch.
- _reconcile_decided() parks each observed changed generation:
  1. _park_intent_copy places the exact bytes, no-replace, in undelivered/
     under a name derived from their SHA-256, so writers converge on one
     copy.
  2. publish_parked creates the item PARKED.
  3. The digest-pinned outbox_cli requeue command is logged.
  4. Only then is the retained generation recorded.
  The retained inode is never handed to deliver_one or moved to a live
  name. The readiness and SETTLE_SEC gate on this path is removed.
- _move_decided() now retains the claim's inode first and places the
  decided copy second. A crash between the two leaves a retained original
  whose reconcile completes the placement. The other order let a crash
  return the descriptor-held inode to recovery as an ordinary claim, which
  could auto-send B.
- _proactive_envelope() is the one builder of a proactive payload: the
  drain and the parked generation both use it.
- tests/_helpers/proactive_lane.py: restart() binds ARCHIVE_RESULTS_DIR as
  production does.

Regressions, through the real Lane unless noted:
- a writer paused past the old SETTLE_SEC (both generations parked, zero
  automatic posts, release of the full digest sends once, no repeat after a
  restart);
- B then C, each parked and released;
- crash cuts after the copy, after the PARKED record and before the digest
  commit;
- both partial-failure orders;
- a two-drainer race through the production writer;
- no clobber across three parked generations;
- empty and unknown-receipt generations;
- tests/outbox-publish-parked.test.py at the writer layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 242f06471d125269ce34dbad0e37b1dae4abb258 (direct parent 9481d423388fa14ca6ab822fb62db5aec4093f73). I accept Option A as the right fail-closed policy: an unprovably complete retained generation should be born PARKED and require a human release. The existing matrix discriminates as claimed: 63/63 gateway tests and 4/4 publish_parked tests pass here; the same test sources against the direct parent produce 26 failures + 6 errors and 8 errors respectively. I also tested the current-main synthetic merge (b211ab9b + this head): those suites remain green, as do the adjacent receipt/error-path checks, vendored parity, git diff --check, and review scans.

[P1] Bind the parked item to the semantic generation, including its route; a digest-pinned release can currently send the prior generation to the wrong room.

_proactive_item_id() hashes only the stem and stripped visible body, expressly excluding room and thread. But _park_generation() builds a route-bearing envelope under that ID. When another raw generation has the same visible body but a different [channel:] or [thread:], publish_parked() correctly returns EXISTS because the stored payload differs; the caller nevertheless treats the existing PARKED receipt/epoch as success, suppresses the release log, and records the new retained digest as settled.

I reproduced this through the real exact-head Lane with these two generations of one retained original:

[channel: !RoomOne:ag2.space]
SAME BODY

[channel: !RoomTwo:ag2.space]
SAME BODY

Before release, undelivered/ held both distinct full-SHA copies, but there was one shared PARKED item whose stored room_id was !RoomOne:ag2.space; only the first generation got a release command. I then ran the same digest-pinned CLI release with the RoomTwo generation's full SHA-256. It exited 0 and restored that exact RoomTwo-marked file, but the resulting provider call was:

POST room_id=!RoomOne:ag2.space body="SAME BODY"
receipt=('DELIVERED', 1)

The same repro fails identically on the current-main synthetic merge. This is not only a diagnostic mismatch: the operator selected RoomTwo by digest and the delivery crossed the wrong room boundary.

There is a second terminal form of the same identity bug. If I first release/deliver the RoomOne generation, then write the RoomTwo generation through the retained descriptor and run six ordinary drains, line 5888 sees the shared item as DELIVERED and returns before making a copy:

posts=[('!RoomOne:ag2.space', 'SAME BODY')]
RoomTwo copies in undelivered=[]
shared receipt=('DELIVERED', 1)
retained=.proactive-1.decided-<RoomTwo raw digest>-ex.*

RoomTwo is silently recorded but can never be released. Thus the implementation does not yet provide “one born-PARKED item per changed generation.” Please give semantically distinct source/envelope generations distinct durable ownership, refuse to record BornParked.EXISTS as this generation's successful park, and add both regressions: same body/new route while the first item is PARKED, and same body/new route after the first item is DELIVERED. A release by raw digest must post exactly that generation's destination/thread, once.

Separately, the pre-approval gates remain open. Exact-head CI attempt 3 is red: python standalone tests (6/9) timed out hermetic-env-structural at 240 seconds, the required aggregate failed, and diff coverage was skipped. Current main shares that timeout, so I am not calling it a PR code regression, but the required gate still needs a green rerun. No exact-head real /startup restart → owner delivery → second restart/no-repeat witness has been posted; the PR body still marks it owed. CLA is green.

Worst case at this head: a digest-pinned operator release sends a retained message to a stale room, or a later routed generation is silently made unrecoverable. Verdict: REQUEST_CHANGES; not merge-ready.

sonichi and others added 5 commits October 25, 2026 06:50
…, so one body to two rooms is two items

_proactive_item_id() keyed an item on the stem and the stripped visible
body only. Two generations with the same body but different [channel:] or
[thread:] markers therefore shared one outbox item. A digest-pinned release
of the second could post to the first's room, and once the first was
delivered, the second was short-circuited as DELIVERED and could never be
released.

- The item identity now also covers the room and thread the file itself
  names: _proactive_identity() parses them once, and _proactive_item_id()
  takes them. A default (owner) room and a task's thread are re-resolved
  each pass and stay out, so an unmarked file keeps its original key
  unchanged.
- Every caller uses that identity: parking (_park_generation), the
  retained-original cache (_retained_item), recovery (_claim_receipt) and
  the drain. _send_proactive() now requires the item id the drain computed
  from the claimed text, so the item born PARKED is the one a release
  claims, sends and marks DELIVERED, with no body-only item beside it.
- A marked claim written before this change is matched to its body-only
  item only when that item's stored payload names the same room and thread
  (_route_matched_legacy_receipt), so an in-flight send is never repeated
  across the upgrade.
- _park_generation() now treats BornParked.EXISTS (the identity is held by
  another payload) as not settled: the generation is not recorded and is
  retried. CREATED, and ALREADY_PARKED with this exact key and payload,
  stay its only successes.

Regressions run through the real Lane: the same body with a new [channel:]
while the first is PARKED (released by its digest it posts to RoomTwo once)
and after the first was DELIVERED (still parked, releasable); the same body
in another [thread:]; a crash after PARKED settling through ALREADY_PARKED;
EXISTS never recorded; and one item end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the hermetic-env-structural timeout fix (#5874) and main's move of
the proactive item id into proactive_routing. The one conflict was the
gateway's _proactive_item_id. It now delegates to
proactive_routing.proactive_item_id, which carries this branch's
route-aware key (src and vendored, byte-identical), so the identity
policy lives in the shared module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved thread, and read [thread:] markers one way

Code-review notes on 4c669c4:

- _route_matched_legacy_receipt() compared the stored thread_root with the
  file's own [thread:] marker. A linked task's thread replaces that marker
  at send time, so a thread-marked file about a task that was mid-send at
  the upgrade read "absent", and orphan recovery could post it twice. The
  stored payload now also matches when it names the room and thread the
  send resolves for this file (_proactive_envelope), as well as the file's
  own marks.
- _marked_thread() (the item key) and _proactive_thread_root() (the send)
  could disagree when a malformed [thread:] came before a valid one. Both
  now read through _thread_marker(), where the first thread marker decides.

Regressions: a thread-marked claim about a task, whose task thread won, is
recovered after the upgrade with no second POST; the key and the send
agree on malformed-first, valid, bare and absent markers. Both fail at
eb78a27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t ownership

Direct tests for branches the hosted diff-coverage gate reported unrun:
- bytes that are not UTF-8 are neither parked nor keyed;
- a legacy item with an unreadable payload, or with no route resolution,
  never matches blindly;
- a snapshot name this process does not own, or cannot take, is left alone,
  and so is one the grammar does not match;
- a superseded parked copy is retained at the requeued epoch;
- an existing placed copy is reused.
Also pins the shared proactive_routing.proactive_item_id: an unmarked file
keeps the body-only key, and a marked room or thread is a distinct item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roduction restart

The legacy-route match is now pinned through the real Lane.restart() and
drains instead of a direct call. Three cases, each a dead owner's
[channel:]-marked claim beside its body-only item:
- the legacy payload is unreadable: kept as its claim, nothing sent;
- route resolution raises: matched by the file's own room, never resent;
- the legacy item names another room: not taken as this claim's item, so
  the claim is sent once to its own room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 0ac290e6abb2c0b48aeb25f6434a82e96ea6e783.

The prior route-collision blocker is fixed for current-version items: room/thread markers now participate in the ID, the parser is shared, and parking through delivery uses that ID. The focused suite is 77/77 at this head; the historical 68-test source passes here and fails 5/68 against 242f0647 as claimed. Three upgrade-path gaps remain.

[P1] Preserve the matched legacy item's identity across restart; otherwise an already-landed send is posted again under the new ID.

_claim_receipt() recognizes a route-matched body-only legacy item but returns only its state/epoch. For READY, orphan recovery hands the claim back live; the ordinary drain then recomputes the new route-aware ID, bypassing the legacy item's dispatch/receipt authority.

I ran the real 242f0647 sender in a child, killed it with os._exit(9) immediately before or immediately after _record_proactive_receipt(), then restarted and drained with this exact head. The transport endpoint alone was fake:

sender -> restart   crash point      first POSTs   extra POSTs   final records
HEAD   -> HEAD      before receipt       1             0        new ID PARKED
HEAD   -> HEAD      after receipt        1             0        new ID DELIVERED ($landed)
242f   -> HEAD      before receipt       1             1        old ID READY + new ID DELIVERED
242f   -> HEAD      after receipt        1             1        old ID READY ($landed) + new ID DELIVERED

The last row is a confirmed duplicate after the provider receipt was already durable. Please carry the exact matched item identity through recovery/drain (or otherwise settle under that one authority) and add a true old-ID -> new-code crash/restart regression, including the recorded-receipt window. The current “mid-send” upgrade test seeds the legacy item directly as DELIVERED, so it cannot exercise this window.

[P1] A missing thread marker is being treated as a wildcard and can suppress a distinct top-level generation.

_route_matched_legacy_receipt() uses thread is None or .... I seeded a legacy DELIVERED item for !RoomOne:ag2.space, thread $OldThread, body SAME BODY, then recovered [channel: !RoomOne:ag2.space]\nSAME BODY with no task/thread marker. Recovery archived it with 0 POSTs. The same-top-level control correctly posted 0; the different-room control posted 1. A top-level destination is an exact semantic route, not “any thread.” Please match the resolved absence of a thread exactly and pin old-thread -> top-level, top-level -> thread, different-thread, and task-resolved-thread transitions.

[P2] Valid non-object legacy JSON aborts startup recovery instead of failing closed.

The legacy matcher validates JSON syntax but not its shape: [] and null both reach stored.get(...) and raise AttributeError at line 6137. I reproduced both through Lane.restart(); each exception left the claim in place, and no later startup work ran. The production entry point calls _recover_orphan_proactive() outside the guarded sweep loop. Treat any non-object/unusable payload as unknown, retain the claim for an operator, and cover valid scalar/list JSON as well as malformed syntax.

Verification at this SHA: gateway-delivered-retire-keeps-witness 77/77, outbox-publish-parked 4/4, outbox-receipt-state 11/11, proactive routing pass, adjacent crash/receipt/thread/lane suites pass, vendored sync and git diff --check pass, and review scans pass for both the cumulative and child layers (positive control rejects a synthetic /Users/reviewer/private addition). Hosted CI is green: 36 pass, 3 expected skips, including diff coverage and CLA.

The exact-head real /startup -> owner delivery -> second restart/no-repeat witness remains a separate approval gate after these code blockers clear.

Verdict: REQUEST_CHANGES — this head is not merge-ready.

sonichi and others added 2 commits October 25, 2026 08:22
…match its route exactly, and recover per item

Upgrade-path review of 0ac290e:

- A marked file sent by the pre-upgrade code under its body-only key was
  recognized by recovery but then drained under the new route-aware key,
  so a send that had already landed was posted again.
  _proactive_identity() now resolves the one item: when the route key has
  no record, it keeps the body-only item that is this generation's
  (_legacy_item_for). Every reader uses it: recovery, the drain, parking and
  the cache. The legacy item's dispatch mark or recorded receipt therefore
  settles it, and it is never resent under a new key.
- The legacy match treated a missing thread as "any thread". The stored
  room and thread_root must now equal exactly what this send resolves,
  where no thread means top level. A linked task's thread still replaces
  the marker's. With no resolution, the file's own marks are compared
  exactly.
- A legacy payload that is valid JSON but not an object raised
  AttributeError and aborted startup recovery. Any unusable payload
  (malformed, scalar, list, null) now reads "unknown", and the claim is kept
  for an operator. Startup recovery and reconcile also guard each item, so
  one failure never stops the others.

Regressions run through the real restart and drain. The pre-upgrade
(body-only) sender dies before and after recording the receipt, and
recovery under the new code makes no second POST and no new-key item. The
thread transitions are old thread to top level, top level to thread, and
thread to another thread, with exact matches as controls. Non-object legacy
payloads keep their claim while another claim in the same restart is still
sent. All fail at 0ac290e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 86b2c86d6b7d674edb161e1fe8da0474d67a892e.

The three reported fixes are real: the old-key pre/post-receipt crash cases now settle without a second POST or new-key item, absent thread is compared as exact top-level, and the new non-object/malformed payload matrix keeps each claim while its startup sibling progresses. The focused file passes 80/80; running those three new tests against unmodified 0ac290e6 produces the claimed 3 failures and 4 errors. Two fail-closed gaps remain.

[P1] Validate the complete legacy envelope before using it as evidence for either “same item” or “different route.”

_stored_payload() accepts every JSON object, and _legacy_item_for() compares only room_id and thread_root. It never establishes that the stored body is this claim’s body or that the route fields have valid types.

I exercised the production restart/drain path with an actual old-key send that reached the fake endpoint, then replaced its payload with {} before recovery:

cut              POSTs before restart   POSTs after restart   bodies
before-receipt            1                       2             SAME BODY, SAME BODY
after-receipt             1                       2             SAME BODY, SAME BODY

Both cuts also create a DELIVERED route-aware item. Other valid-but-unusable objects do the same: missing/invalid room or invalid thread means “different route” and permits a new POST, while a matching room with a missing, non-string, or different body is accepted as the delivered legacy item. The authored matrix at line 1450 covers [], null, 5, "text", and malformed {, but not {} or contradictory object fields.

Please distinguish a fully valid legacy envelope from an unusable one before route comparison: an unusable/contradictory object is unknown and keeps its claim; only a valid envelope may prove an exact match or a genuinely different route. Pin {}, missing/wrong/non-string body and invalid room/thread types, while retaining the valid different-route and absent-thread controls.

[P2] Preserve the receipt reader’s bounded/nonregular-file guarantees when reading the payload, and isolate ordinary reconciliation per item.

The safe receipt() and the later read_item() payload read are separate operations. In a controlled race, replacing the item with a FIFO after receipt() returned DELIVERED made the second read block past a 0.5 s deadline and stopped recovery. A nonregular item already present likewise makes _stored_payload() raise; because _reconcile_changed_decided() has no per-item guard and runs before the live-file loop, two consecutive drains raised IsADirectoryError, posted zero siblings, and left the healthy sibling live. Startup’s loops do catch each item, but _retry_pending_snapshots() and ordinary retained reconciliation do not.

Read receipt and payload from one validated, bounded, no-follow snapshot (or give the second read the same guarantees), and add per-item isolation to the ordinary snapshot/retained loops so one uncertain item stays retained while a healthy live sibling is delivered.

Verification at this SHA: focused file 80/80; adjacent outbox/proactive/result suites pass; vendored sync, git diff --check, cumulative and child-layer review scans pass; the scanner positive control rejects /Users/reviewer/private. Hosted CI is green (36 successes including CLA, 3 expected skips). Current main is one unrelated commit ahead and the synthetic merge is clean.

The real exact-head /startup → owner delivery → second restart/no-repeat witness remains the approval gate after the code blockers clear.

Verdict: REQUEST_CHANGES — this head is not merge-ready.

sonichi and others added 4 commits October 25, 2026 09:02
…is body, from one safe read, and isolate drain reconciliation per item

Review of 86b2c86:

- Any JSON object was accepted as the legacy item's payload, so {} or an
  object with a missing, wrong or ill-typed body, room or thread counted as
  "a different route", and a landed send was posted again under a new key.
  _legacy_envelope() now accepts only a complete envelope for exactly this
  body: body equal to this text, room_id a non-empty string, thread_root
  absent or a non-empty string. Only such an envelope can prove a match or a
  different route. Any other record keeps the legacy item and reads
  "unknown", so the claim is kept for an operator and never resent.
- The payload was read separately from the receipt, without its guarantees,
  so a FIFO swapped in between blocked recovery, and a directory raised.
  outbox.receipt_record() (src and vendored, byte-identical) returns state,
  epoch and record from one bounded, non-blocking, no-follow read of a
  regular file; receipt() is built on it, and the gateway reads the legacy
  payload only through it.
- Ordinary drains now guard each retained original and each pending snapshot
  claim. One uncertain item is retried on a later drain and never stops the
  live-file loop.

Regressions run through the real restart and drain. The pre-upgrade sender's
landed POST, at both receipt cuts, with nine unusable envelopes ({}, missing
or wrong or non-string body, missing or non-string or empty room, invalid
thread), causes no second POST and no new-key item, and the claim is kept.
A FIFO or directory legacy item never blocks or stops recovery, and a
sibling is still sent. An ordinary drain with a directory legacy item, a
raising original and a raising snapshot still sends its healthy sibling.
All fail at 86b2c86.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e retained and snapshot reconciliation in separate drains

- The non-regular legacy record matrix now also covers a symlink to a
  well-formed record (refused by no-follow) and a record past the read bound
  (refused before it is parsed), beside the FIFO and the directory.
- Drain isolation is split in two, so each fails on its own at a head
  without it: retained reconciliation (a directory legacy item and a raising
  original), and snapshot reconciliation (a raising snapshot beside a
  sibling snapshot that is still sent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… scan reserves

tests/delivery-core-enforcement.test.py requires every function named
`reconcile` in the tree to take `(attempt)`, the DeliveryAttempt contract.
The isolation test's local wrapper was named `reconcile`; it is now
`raising_for_one`. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@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.

Comment at 4dad3726e (86b2c86d + 28a74fe6c, 62435c232, 4dad3726e, and a merge of main at a0e25c589). Kewei's P1 and P2 from 86b2c86d look fixed. I checked each one with a mutation, not just by reading. I'm not approving, because nobody has approved this head: Kewei's latest is CHANGES_REQUESTED at 86b2c86d, and Qingyun's approvals are at older heads.

Kewei's 86b2c86d asks: fixed (checked by mutation)

All line numbers are in packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py at this head.

  1. [P1] Only a complete envelope can prove a match or a different route. _legacy_envelope (:6149-6165) accepts a payload only when body equals this text, room_id is a non-empty string, and thread_root is absent or a non-empty string. Anything else reads as unknown and the claim is kept. Mutation: accepting any dict makes test_a_landed_old_key_send_whose_envelope_is_unusable_is_kept_and_never_resent fail in 18 of 18 subcases (9 payloads, {} included, at both receipt cuts).
  2. [P2] One safe read for the receipt and the payload. outbox.receipt_record does one bounded, non-blocking, no-follow read of a regular file, and the gateway reads the legacy payload only through it. Mutations:
    • Going back to a separate receipt() + read_item() read fails the FIFO case of test_a_legacy_item_that_is_not_a_regular_file_never_blocks_or_stops_recovery.
    • Lifting the 4 MiB bound means the suite no longer passes. The process is killed instead of going green.
    • src/outbox.py and the vendored copy are byte-identical.
  3. [P2] Per-item isolation in ordinary drains. _reconcile_changed_decided (:5887-5899) and _retry_pending_snapshots (:6031-6037) each guard their own items. Removing either guard fails its own test (test_an_ordinary_drain_isolates_each_retained_original, test_an_ordinary_drain_isolates_each_pending_snapshot).

One small, non-blocking note: _claim_receipt (:6142-6145) no longer checks state not in ("absent", "unknown") before the envelope test. So if an adopted legacy item disappears between the identity read and this read, the result is now "unknown" instead of "absent". That fails closed (the item is kept, not resent), so I'm not asking for a change.

What I ran at this head (local, isolated env)

  • gateway-delivered-retire-keeps-witness 84 OK
  • outbox-receipt-state 11 OK
  • outbox-publish-parked 4 OK
  • gateway-proactive-recovery-edges PASS
  • proactive-routing exit 0
  • result-publish 30 OK
  • task-transition 113 OK
  • task-archive 51 OK
  • task-orphan-check-classify 155 OK
  • delivery-core-enforcement 15 OK

I did not re-verify the merged main content (#5686).

CI and CLA

  • license/cla: success (1 context).
  • CI: green. 36 checks succeeded and 3 were skipped as expected.
  • Unresolved review threads: 0.

What's missing before approval

  1. An approval at 4dad3726e. Kewei has to re-review these fixes herself.
  2. The criterion-15 live witness. It is named here, not blocking for me: a real /startup restart at this head, one real owner delivery, and a second restart with no repeat. Kewei treats it as required before her approval.

This account's CHANGES_REQUESTED at de30fbe4 stays on record until it approves. Its asks were cleared at f593ee3e0.

— 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 4dad3726e1bcdecd89601f774bc984c068bffa4c.

The two code blockers from my review at 86b2c86d are resolved:

  • _legacy_envelope() now requires a complete envelope for this exact body before it can prove either a legacy match or a different route. The before/after-receipt matrix covers {}, missing/wrong/non-string body, missing/non-string/empty room, and invalid thread; every case keeps the old-key claim and makes no second POST or route-aware item.
  • outbox.receipt_record() gives the payload path the receipt reader's bounded, non-blocking, no-follow regular-file guarantees, and ordinary retained/snapshot reconciliation now isolates each item. FIFO, directory, symlink, oversized-record, retained-original, and pending-snapshot cases all progressed their healthy sibling.

Exact-head verification:

python3 tests/gateway-delivered-retire-keeps-witness.test.py
Ran 84 tests in 11.821s — OK

python3 tests/outbox-receipt-state.test.py
Ran 11 tests in 0.149s — OK

python3 tests/ag2-sparrow-drift.test.py
PASS — ag2-sparrow bundled utils in sync with src/

git diff origin/main...HEAD | bash scripts/review-checks.sh
review-checks: PASS

git diff --check origin/main...HEAD
clean

Hosted CI and CLA are now green at this SHA; the three skipped jobs are intentional.

Blocking evidence gap: the PR body still says the exact-head live witness is owed. This changes /startup and the proactive bridge/delivery loop, so harness evidence cannot satisfy REVIEW.md criterion 15. Please run and paste the real 4dad3726 witness: restart through /startup, observe one owner delivery end-to-end, then restart again and show that delivery does not repeat.

Verdict: REQUEST_CHANGES — the code layer is clear, but this head is not merge-ready until that real post-restart witness is recorded. If the head remains unchanged and that witness passes, this is ready for my approval.

This branch has not been deployed

No deployments
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.

4 participants