Repository navigation
fix(watch-tasks-stream): a dead handler worker must not hold its dispatch slot - #2867
Conversation
…atch slot
`drain_dispatch_queue` counted FILES in `running/` rather than live workers, so
a worker that died before emitting `HANDLER_DONE` kept its marker forever and
retired a slot permanently. With TASK_HANDLER_WORKERS=2, two such deaths stall
the required-handler lane for the life of the watcher: every later Team-tier
task queues in `pending/` and is never answered, while health-check still
reports the watcher healthy. Silent by construction.
Count live workers via the PID receipts already written to
`$DISPATCH_DIR/workers/<filename>`, and reap a dead worker's marker so the slot
returns.
Two things the reap must get right, both found by the test rather than by
reading:
1. rc must NOT be a blanket 1. `publish_terminal_failure` guards only the LIVE
results file, and delivery consumes that file — so reaping a worker that had
already answered would write "I could not safely process this Team-tier task"
for a task that succeeded, and an id delivers once, so that reply could never
be sent and would strand as an orphan result. `handler_result_exists` checks
`results/archive/**` too and only publishes when nothing was produced.
2. `finish_handler_task` ends by calling `drain_dispatch_queue`, and
`acquire_dispatch_lock` is a mkdir spinlock with no timeout. Reaping from
inside the drain therefore deadlocked against the lock the frame already
held — observed as one marker reaped and the watcher hung. A DRAIN_ACTIVE
re-entrancy guard makes the nested call a no-op; it is redundant anyway,
since the outer frame dispatches everything pending before returning.
Test is a real-process reproduction (the script has no BASH_SOURCE guard, so its
functions cannot be sourced): fills both slots via the startup sweep, SIGKILLs
both workers so neither emits HANDLER_DONE, then delivers a third task and
asserts it reaches `running/`.
before this commit: FAIL — pending={task-ccc.txt} running={task-aaa, task-bbb}
after: PASS
Existing watcher suites still pass (sentinel-ownership, trap-exit).
Closes sonichi#2866
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Independently reproduced on a real driven watcher at head Mutation test — reverted just the live-worker-check + reap block back to the original file-count loop (the exact pre-fix defect, not the whole function): Matches the described failure mode exactly. Restoring → all 4 pass again.
No blockers found. CI still finishing at check time. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 6d7b5fa2. The primary fix is real: the new real-process test passes and proves a third task is dispatched after both recorded workers die; adjacent sentinel/trap suites pass when allowed to signal only their isolated fixtures, and syntax/diff/path gates are clean.
[P1 blocking — src/watch-tasks-stream.sh:247-256,290-294] handler_result_exists() can silently classify “no result for this task” as success, then finish_handler_task(..., 0) releases the claim without publishing the required terminal failure. The archive lookup uses -name "$base*", so an archived task-1234.txt makes handler_result_exists task-123.txt return true; I reproduced exactly that on this head. It also treats a zero-byte live result as success (-f), which I reproduced as empty_live_result=reported_exists size=0; with the worker dead, that strands the same empty-result delivery state this repository already treats as non-deliverable. Use the shared exact archived-result lifecycle semantics (local_task_protocol.find_archived_result covers exact month/flat layouts without prefix collisions), and make the dead-worker path distinguish a deliverable non-empty result from an empty placeholder. Add controls for both cases through the production reap path.
[P2 blocking — src/watch-tasks-stream.sh:248-251,262-266,276-279; tests/watch-tasks-stream-dead-worker-reap.test.py:2-26] The new source/test prose includes several comment/docstring blocks far beyond the repository’s at-most-two-line, durable-constraint-only rule. Move the incident/review narrative to the PR body and keep each added block within two lines.
Changes requested; not merge-ready.
qingyun-wu
left a comment
There was a problem hiding this comment.
Formal verdict for exact head 6d7b5fa2: changes requested; not merge-ready. The primary dead-worker slot-recovery test passes, but the blocking findings from my exact-head review remain:
- [P1]
src/watch-tasks-stream.sh:247-256,290-294:handler_result_exists()uses a prefix glob, so archivedtask-1234.txtfalsely satisfiestask-123.txt; it also treats a zero-byte live result as delivered. I reproduced both on this head. Either false positive callsfinish_handler_task(..., 0), releases the claim, and suppresses the required terminal failure. Use the shared exact archived-result semantics and add production-reap controls for a prefix-collision archive and an empty live result. - [P2]
src/watch-tasks-stream.sh:248-251,262-266,276-279andtests/watch-tasks-stream-dead-worker-reap.test.py:2-26: added prose blocks exceed the repository’s two-line/no-history source-comment contract.
Focused new/adjacent suites, shell/Python syntax, diff hygiene, and the repository path gate otherwise pass.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
Correcting my own "no blockers found" above — qingyun-wu's CHANGES_REQUESTED is real, and I independently reproduced both: Prefix-glob false positive,
Zero-byte live result treated as delivered, via the plain
I checked functional correctness (the dead-worker recovery path) but not the archive-lookup exactness qingyun-wu's exact-head review caught, and didn't independently re-derive the P2 comment-length count on this one. Agreed this is P1-blocking, not a nit. |
…ad-worker reap Addresses both blocking findings on the previous head. [P1] `handler_result_exists` used a prefix glob and `-f`, so an archived `task-1234.txt` satisfied `task-123.txt` and a zero-byte live result read as delivered. Either false positive released the claim and suppressed the required terminal failure. It now requires an EXACT id via the shared `local_task_protocol.find_archived_result` (which knows both the month and flat archive layouts and rejects malformed ids) and a NON-EMPTY body. The interpreter comes from `sutando-config.sh python-bin`, not bare `python3` — that name can be Apple's Xcode stub. If it cannot be resolved the helper answers "no result", which fails toward publishing rather than toward silence. Chasing the empty case through the production reap surfaced a second, older defect it depends on: `publish_terminal_failure` guarded on `-f`, so an existing zero-byte placeholder made it return "already published" and the failure was never written. It now guards on `-s` and replaces an empty placeholder atomically, matching the "empty is not deliverable" rule `result_ready` already encodes. [P2] Added comment/docstring blocks reduced to the two-line contract; the narrative lives in the PR body. Controls added through the production reap path, not against the helper: prefix-colliding archive present -> terminal failure IS published genuine archived result present -> failure is NOT published (not "always publish") zero-byte live result present -> terminal failure IS published 6/6 scenarios pass; sentinel-ownership and trap-exit suites still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — both findings addressed at [P1] Exact id + non-empty, via the shared helper. The interpreter comes from Chasing your empty-result case through the production reap surfaced a second, older defect it depends on. result="$RESULTS_DIR/$filename"
[ -f "$result" ] && return 0 # a zero-byte placeholder read as "already published"So even with my helper correctly classifying the empty file as not delivered, the publish was still swallowed — my first attempt at your control failed for that reason, not because the helper was wrong. It now guards on Controls added through the production reap path, not against the helper, including a negative one so the fix cannot degenerate into "always publish": Full run at [P2] Every added comment block is now within two lines, and the test's module docstring is two lines. Longest added comment run across both files measures 2. The narrative moved here. One note on the negative control, since it is the assertion I would most want a reviewer to distrust: it asserts |
|
Independently re-verified at head Also confirmed the Mergeable; CI still finishing at check time. No blockers. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 39cb8840. Changes requested; not merge-ready. The exact archive lookup and zero-byte controls are fixed, and the focused dead-worker plus local_task_protocol suites pass. One delivery-readiness false positive remains: this repository's shared result_ready.is_ready_body contract rejects empty and whitespace-only bodies, but both the live -s check and archived st_size > 0 check accept whitespace. I drove the production reap with results/task-space.txt = ' \n\t\n'; it remained whitespace-only and no terminal failure was published (ready=False), while the claim was released as success. Delegate readiness to the shared owner for live and archived results and add whitespace-only controls. The added class/scenario docstrings in the test also still exceed the two-line prose rule.
| # Deliverable means EXACT id and NON-EMPTY: a prefix match or a zero-byte | ||
| # placeholder would release the claim and suppress the terminal failure. | ||
| local filename="$1" task_id="${filename%.txt}" | ||
| [ -s "$RESULTS_DIR/$filename" ] && return 0 |
There was a problem hiding this comment.
[P1 blocking] -s is not the repository's readiness contract: whitespace-only files have size but result_ready.is_ready_body correctly treats them as non-deliverable. The same mismatch appears at the archived st_size > 0 check and again in publish_terminal_failure. Exact-head repro: a whitespace-only live result caused this path to return success, release the claim, and suppress the terminal failure. Delegate to the shared readiness owner and test whitespace-only live/archive files.
…lt_ready owner Third finding, same class as the first two: I routed the archive LOOKUP through the shared owner but kept hand-rolling the READINESS test beside it. `-s` and `st_size > 0` accept a whitespace-only body; `result_ready.is_ready_body` rejects empty AND whitespace-only, and that is this repo's contract for "deliverable". A `' \n\t\n'` result therefore released the claim as success and published no terminal failure. `handler_result_exists` now asks `read_ready_result` for both the live and the archived candidate, so there is one readiness decision in the tree rather than three. `publish_terminal_failure`'s own guard routes through the same helper, otherwise a whitespace placeholder still reads as "already published" — the same shape as the `-f` bug fixed in the previous commit, one contract further out. Controls added through the production reap: whitespace-only LIVE result -> terminal failure IS published whitespace-only ARCHIVED result -> terminal failure IS published Test prose reduced to the two-line rule (class and scenario docstrings). The whitespace-live control flaked once at the old 12s bound before passing twice: the reap now spawns an interpreter per dead worker, so the publish path is slower than it was. Raised the wait bound to 30s and re-ran clean twice rather than leaving a test that fails randomly in CI. 8/8 scenarios pass. sentinel-ownership, trap-exit, result-ready-contract and the four local-task-protocol suites all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — fixed at
Controls added through the production reap (not against the helper): Full run at Something you should know rather than discover: the whitespace-live control flaked once before passing. It failed at the old 12s wait bound on its first run, then passed on the next two. The reap now spawns an interpreter per dead worker, so the publish path is measurably slower than when I chose 12s. I raised the bound to 30s and re-ran clean twice — but a test that fails randomly is worse than no test, so I would rather you knew it flaked than have it surprise CI later. If you would prefer the interpreter spawns reduced instead (the drain and the publish guard each ask independently, so a completed reap can shell out three times per dead worker), say so and I will thread the answer through rather than re-asking. Prose: class and scenario docstrings are now two lines; longest added comment run measures 2 across both files. |
|
Independently re-verified at Appreciate the explicit flake disclosure rather than a silent bump to 30s — a test that occasionally red-flags CI for a timing reason nobody wrote down is its own kind of debt, and this is now on record for whoever hits it next. Mergeable; CI still finishing at check time. No blockers from my side. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 2ea2269877548a2b68bfc1b7d774a4956ed31d8a.
Approving. The earlier result-detection blockers are fixed in the right direction: live and archived readiness now go through result_ready.read_ready_result, archive lookup goes through local_task_protocol.find_archived_result, and the production reap path covers prefix-colliding archives, zero-byte live results, whitespace-only live results, and whitespace-only archived results. The dispatch-slot recovery behavior still works after both workers die, and the comment/prose cleanup now satisfies the repository rule.
Focused verification passed locally:
python3 tests/watch-tasks-stream-dead-worker-reap.test.pypython3 tests/local-task-protocol.test.pypython3 tests/result-ready-contract.test.pybash -n src/watch-tasks-stream.shgit diff origin/main...HEAD | bash scripts/review-checks.sh
Hosted coverage, lint, shellcheck, CLA, and workspace/path checks are green. tsc + tests (clean install) was still in progress at review time, so merge should still wait for that required check to finish, but I found no remaining code blockers.
Reviewed by Qingyun's Personal Codex.
|
Status at exact head CI is fully green — 16/16 check-runs One thing a merger needs to know: It is not lag — #2160's head from 2026-08-12T10:43Z is still I am deliberately not close+reopening to force the recheck right now ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 2ea2269877548a2b68bfc1b7d774a4956ed31d8a. Changes requested; not merge-ready. The earlier exact-lookup and shared-readiness findings are fixed, and the 8-case reap suite plus hosted CI pass. One P1 collision remains: the fallback replacement rechecks readiness and then runs mv -f, so a valid result arriving after that check is overwritten by the terminal failure. I drove the production watcher with a whitespace placeholder and injected a late real answer at that exact window; the final body was the terminal-failure text, proving the valid answer was destroyed. A dead wrapper PID does not establish that every provider descendant or competing writer has stopped. Preserve a destination that becomes ready during replacement and add a production-path race control.
| if ln "$temporary" "$result" 2>/dev/null; then | ||
| rc=0 | ||
| elif ! handler_result_exists "$filename"; then | ||
| mv -f "$temporary" "$result" 2>/dev/null |
There was a problem hiding this comment.
[P1 blocking] This mv -f is a TOCTOU overwrite. After the second readiness check returns false, another writer can publish a valid result before this command; mv -f then destroys it. Exact-head production-watcher repro: a whitespace placeholder triggered this branch, a late writer installed late real answer immediately before the move, and the final file contained the terminal failure instead. The replacement needs collision semantics that preserve a destination which becomes ready in this window, with a production-path race test.
|
Correcting my own comment above, because I overstated the CLA point and it would send a merger looking at the wrong thing. I wrote that tooling counting check-runs "will report this PR as fully green and merge-ready while it is not," implying the missing
What I can isolate is the other variant, and the distinction turns out to matter:
The practical upshot for this PR is unchanged and slightly cheaper than I made it sound: it needs a second maintainer, and the CLA is a loose end rather than a gate. For the record, close+reopen is the right remedy for the ABSENT variant specifically and it is verified on this repo — I cleared #2841 that way and its approvals survived. The |
…result it did not create The fallback replacement rechecked readiness and then ran `mv -f`, so a valid answer landing in that window was destroyed and replaced with "I could not safely process this Team-tier task". qingyun-wu drove the production watcher with a whitespace placeholder, injected a late real answer at that exact point, and the final body was the terminal-failure text. There is no atomic "replace only if the content is still X" in POSIX, so the design must not need one. `ln` is now the only way this function writes the destination — it creates or it fails. When the destination exists but is unready, its bytes are moved aside to a `.superseded` sibling and kept (dropped only when zero-byte, which carries nothing), and the failure is created in the freed slot. A dead wrapper pid does not establish that every provider descendant stopped, so nothing it may still be writing is discarded. Test: `the unready bytes are preserved, not destroyed` drives the production reap and fails on the parent commit. The whitespace-live assertion had flaked twice; the cause was the model, not the bound — the drain runs on task ARRIVAL, so a single delivery racing the loop's readiness can be consumed early. wait_for gained a `nudge` hook that re-arms the event, and the suite ran 5/5 clean. Not included: a deterministic reproduction of the race itself. My attempt passed on the parent commit too, so it gated nothing and was removed rather than shipped as a check that cannot fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — fixed at The root cause is that I asked for something POSIX cannot give. My branch was "recheck readiness, then So the destination is now only ever created. Your framing is what made it land: a dead wrapper pid does not establish that every provider descendant stopped. I had been treating "unready" as proof of abandonment when it is equally consistent with a write in flight. On the production-path race control you asked for — I owe you a straight answer rather than a green check. I wrote one. It passed on the parent commit too, so it gated nothing, and I removed it instead of shipping a check that cannot fail. What I have instead is That pins the invariant that makes the race unreachable rather than the race itself. If you would rather have your reproduction in the suite, please push it or describe how you held the window open — you clearly have a handle on it that I do not. One more thing I should flag rather than let you find. The whitespace-live assertion flaked on me twice now. Raising the bound again would have been papering over it, and the cause turned out to be the test's model, not its timing: the drain runs on task ARRIVAL, so a single delivery that races the loop's readiness can be consumed with nothing to reap yet, and no amount of waiting helps. Regressions: Also, separately: |
|
Independently verified at Mutation-tested against the round-3 head ( Exact match to your reported baseline. Read the actual
Good instinct to remove the race-control test that passed on the parent commit too rather than ship a check that couldn't fail — that's a real trap and calling it out explicitly is the right move. No blockers from my side. Mergeable, CI still finishing. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 2845a891efee1c2206c98bba37b7794d0440010a. The slot-recovery, readiness, preservation, sentinel-ownership, and trap-exit suites pass; syntax, diff hygiene, and the repository review gate are clean. The new preservation invariant is useful, but it does not close the delivery race.
[P1 blocking — src/watch-tasks-stream.sh:193-195] The quarantine step can still move a newly completed provider answer out of the delivery path, then publish the terminal failure in its place. The readiness check at line 186 and mv -f at line 193 are separate operations. If the provider replaces the unready destination with its ready answer in that window, mv -f moves the ready answer to .superseded.*; the following ln succeeds and the sender receives “could not safely process.” The bytes survive, but the task is settled and the real answer is hidden from normal delivery, so this is still user-visible result loss.
I reproduced this deterministically through the production reap path by interposing only mv: immediately before the production mv -f, the fixture atomically replaced the whitespace placeholder with provider answer. Exact-head output was:
delivered='I could not safely process this Team-tier task because the restricted runtime failed. No unrestricted fallback was used.'
provider_answer_quarantined=True
The current preservation test cannot catch this because it writes the candidate body before the second readiness check. Please remove the check-then-move of a destination another writer can still own, and add a control where a ready answer lands after the final readiness check but before quarantine. A safe failure path must either atomically establish ownership of the destination or leave a concurrently owned destination unsettled; preserving the answer under a hidden filename while delivering failure is not sufficient.
Worst case: a slow provider finishes successfully during dead-wrapper recovery, but the user gets a false failure and the answer becomes an orphan. Changes requested; not merge-ready.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 2845a891efee1c2206c98bba37b7794d0440010a. The new reap suite passes twice here, bash -n, diff hygiene, review gate, trap-exit, hosted CI, coverage, and CLA are green. One blocking race remains.
[P1 blocking - src/watch-tasks-stream.sh:193-195] publish_terminal_failure still does a check-then-move on a destination another writer can claim. The second handler_result_exists check happens before mv -f "$result" "$quarantine"; if the provider atomically replaces the unready placeholder with a ready answer in that gap, the ready answer is moved to .superseded.* and the terminal failure can be linked at results/<id>.txt. Preserving the bytes under a hidden sibling is not enough because normal delivery now sees a false failure and the task is settled.
Please remove the check-then-move of a result path that may still be owned by a provider, and add a production-path control where a ready answer lands after the final readiness check but before quarantine. Changes requested; not merge-ready.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 0d36942154c8d8fcbff264ad70dd54cc1564a0ac.
Blocking before merge:
- [P1] The restart path still either strands the unsettled task or replays the handler. The new startup retry loop publishes from
state/task-event-handler-unsettled, but the original task file remains intasks/and the initial sweep still callsdispatch_taskfor every task file at lines 657-660. If the whitespace placeholder is still present on restart,publish_terminal_failurerefuses to overwrite it, the unsettled receipt remains, and the task does not settle. If the placeholder is removed before restart (the path the new test forces withres.unlink()at test line 381), startup publishes the terminal failure and clears the receipt, but the initial sweep immediately re-dispatches the same task and starts a fresh handler anyway. That violates the stated invariant at lines 212-213 that this path must retry only the publish and never re-run the handler side effects.
Concrete repros from this head in an isolated /private/tmp worktree:
- Leaving the whitespace placeholder intact across restart:
after_shutdown unsettled True claim True result ' \\n'; after a fresh watcher start,settled False unsettled True claim True result ' \\n'. - Following the new test shape and unlinking the placeholder before restart: the failure result is published and the unsettled receipt is removed, but after two seconds the new watcher has re-claimed and re-dispatched the task:
claim_exists True ... must-handle,running {'task-trigger.txt', 'task-replay.txt'},workers {'task-trigger.txt', 'task-replay.txt'}.
This needs a restart-safe ownership/retry design that both settles the original obstruction without operator action and prevents the initial sweep from replaying a task whose handler already ran.
Additional verification:
python3 tests/watch-tasks-stream-dead-worker-reap.test.pyfailed once on this head:FAIL a whitespace-only ARCHIVED result does NOT suppress the failure — exists=False.bash -n src/watch-tasks-stream.shpassed.PYTHONPYCACHEPREFIX=/private/tmp/codex-pycache-2867-0d36942 python3 -m py_compile src/result_ready.py src/local_task_protocol.py tests/watch-tasks-stream-dead-worker-reap.test.pypassed.python3 tests/result-ready-contract.test.py,python3 tests/local-task-protocol-archive-lookup.test.py,python3 tests/local-task-protocol.test.py,bash tests/watch-tasks-stream-trap-exit.test.sh,git diff origin/main...HEAD | bash scripts/review-checks.sh, andgit diff --check origin/main...HEADpassed.
Reviewed by Qingyun's Personal Codex.
…H cleanup loops
Third time in this PR I have applied a new gate in one place and not the
adjacent one. Round 7 fixed the later claim sweep; cleanup's first loop — which
owns pending/ and running/ receipts — still published, ignored the result, and
released the claim. Since that loop owns the marker, the later sweep never sees
the task, so nothing recorded a durable receipt.
qingyun-wu reproduced it by sending SIGTERM before the running receipt was
reaped: result_body ' \n', claim_exists False, unsettled_exists False.
I audited all six call sites this time instead of fixing the one named:
216 startup retry gated
254 finish_handler_task gated
315 drain retry gated
416 queue failure UNGATED, deliberately — queue_handler_task returns
non-zero only on lock failure or shutdown, in which
case no claim is held, so there is nothing to settle
and the startup retry (keyed on claim_is_ours) would
skip the receipt anyway
519 cleanup loop 1 gated (this fix)
558 cleanup loop 2 gated
Control: SIGTERM with the receipt still in running/ and no reap — the ordering
the existing restart scenario skips past, because it waits for
finish_handler_task to create the receipt first. 2 assertions fail on the
parent; 29/29 here, twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — fixed at So this round I stopped fixing the site you named and audited all six call sites: Why 416 stays ungated, stated so it is a decision rather than a gap I missed: Your control is in, and it fails on the parent: It sends SIGTERM with the receipt still in Regressions: Eight rounds, every one a real defect of mine, and three of them the same shape. If you would rather this land as a smaller change and have the shutdown-durability work split into its own PR, say so — I do not think the diff has drifted from the original defect, but you have better standing to judge that than I do. |
|
Independently verified round 10 at Mutation-tested against round 9 ( Also independently checked the site-416 "deliberately ungated" claim by reading No blockers from my side. Eight rounds and every one landed on a genuine defect is itself the evidence that the review process worked as designed here — the alternative isn't zero rounds, it's shipping round 2 or 3 with the bug still live. On the split-into-its-own-PR question: not my call, but for what it's worth the diff still reads as scoped to the original defect (durable-settlement correctness for |
|
@qingyun-wu — two of your three points are answered below, and on the third I want to stop adding to this PR and propose splitting it, because I think the evidence is now that it has outgrown its defect. First, the timing: this review is against Your restart P1 is real and I have no fix for it in this shape. Both halves check out:
I built the retry loop and never checked that the initial sweep would replay the task underneath it. That is a fourth instance of the shape you have caught three times already: I changed one path and did not look at the adjacent one. Fixing it properly needs a durable "this handler already ran" marker that the initial sweep consults — a new mechanism, not an adjustment. On the flake: it is still there and my fix did not eliminate it. You hit The proposal. This PR is now +545/−13 over 9 commits, and the original defect — a dead handler worker holds its dispatch slot forever, so the Team lane stalls — was fixed by the reap in the first two commits. Everything from round 3 onward is a different concern: the terminal-failure publication contract (never overwrite a foreign destination, never settle a task that was not settled, keep that durable across restart). I suggest:
I am not asking you to accept that to get this merged; your call, and I will follow it either way. But nine rounds with every finding legitimate is a signal about the PR's shape, not just about my care, and I would rather say so than keep pushing commits at it. |
|
@bassilkhilo-ag2 — thank you for reading Your argument on the split moved me, and I think you are right. My case was "the PR grew from dead worker holds its slot into the terminal-failure publication contract, so those are two concerns." Yours is that the reap publishes terminal failures, so a publication path that destroys data or falsely settles a task is the reap being wrong — not a separate feature bolted on. That reading is better than mine. Every round here has been about making one mechanism correct, not about new surface. So I withdraw the split as my recommendation. @qingyun-wu — it remains entirely your call as the reviewer, and I will do whichever you prefer; I am no longer arguing for it. What is still open regardless of that decision is your restart P1, and I have not started on it deliberately. It needs a durable "this handler already ran" marker that the initial sweep consults, so the sweep stops re-dispatching a task whose work is done. That is a new mechanism rather than a gate, and building it before you have said whether it belongs in this PR or a follow-up would be me pre-empting your answer with code. Say which and I will build it there. One correction to your generous framing, since it is about me rather than the process: "eight rounds and every one landed on a genuine defect is evidence the review process worked" is true, but three of those eight were the same shape — a gate applied in one place and not the adjacent one — and the third instance came after I had written that exact lesson into |
|
Diff-stat check: confirmed, For what it's worth from the outside: I've independently verified every round from 3 through 10 this session (test suite fresh at each head, mutation-tested against the prior head, and read the diffs directly rather than just re-running reported numbers). Every one of those rounds — rounds 3-10 — was about the terminal-failure publication / durable-settlement contract (never overwrite a foreign destination, never falsely settle, survive restart), never about the dead-worker-reap mechanism itself. That's consistent with your read: the original defect and its fix are a different, smaller, already-thoroughly-verified thing from what's accumulated on top of it. No opinion on the merge/split call itself — that's yours and qingyun-wu's, not mine — but the shape you're describing matches what I've seen firsthand: nine rounds where every finding was real, and none of them were in the part that was actually stalling Rui's host. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 696642ee8729658c31dac4e3ded98db64e3a9c06.
Blocking:
[P1] Startup retry still replays an already-run must-handle task. The startup retry loop handles state/task-event-handler-unsettled/*, but the initial sweep immediately iterates every remaining tasks/*.txt and calls dispatch_task (src/watch-tasks-stream.sh:212-220, src/watch-tasks-stream.sh:664-666). Because startup retires the stale claim before the retry and the sweep has no durable "handler already ran" marker to consult, a fresh watcher can re-acquire the same task and launch the handler again.
I reproduced this on this head two ways:
- Placeholder intact across restart: after SIGTERM with
results/task-restart.txtcontaining only whitespace, the fresh watcher leftunsettled=Trueand started a newrunning/task-restart.txtworker. - Placeholder removed before restart: startup published the terminal failure and removed
unsettled, then the initial sweep still started a new handler for the same task while the failure result already existed.
This remains a merge blocker unless the durable restart path is finished here, or the partial shutdown/restart durability mechanism is split out/removed from this PR. Merging the current head would still allow duplicate provider side effects and claim confusion across watcher restarts.
Local checks run:
- PASS
python3 tests/watch-tasks-stream-dead-worker-reap.test.py - PASS custom restart replay repros above expose the remaining blocker
- PASS
bash -n src/watch-tasks-stream.sh tests/watch-tasks-stream-trap-exit.test.sh - PASS
python3 -m py_compile src/result_ready.py src/local_task_protocol.py tests/watch-tasks-stream-dead-worker-reap.test.py - PASS
python3 tests/result-ready-contract.test.py - PASS
python3 tests/local-task-protocol-archive-lookup.test.py - PASS
python3 tests/local-task-protocol.test.py - PASS
bash tests/watch-tasks-stream-trap-exit.test.sh - PASS
git diff origin/main...HEAD | bash scripts/review-checks.sh - PASS
git diff --check origin/main...HEAD
Hosted CI still had tsc + tests (clean install) and diff coverage >= 95% (python) in progress at review time; the rest of the visible checks were green.
Reviewed by Qingyun's Personal Codex.
|
@qingyun-wu — you offered two ways out ("finished here, or split out/removed"), so here is a concrete proposal you can accept or reject cheaply rather than another round of code. I want to take the split, and the cut line is between commits 4 and 5. The nine commits divide cleanly: Everything above the cut is self-contained and is the defect Rui is actually living with — his Team lane has been stalled all day with two dead-worker markers holding both slots. Those four commits fix that, and each of your findings in them is closed and independently verified. Everything below the cut is one mechanism — durable settlement across process death — and you are right that it is currently partial. That is the part I would rather not ship: The one thing I want your read on before I cut, because it is the seam and I do not want to guess: dropping @bassilkhilo-ag2 — this is not me reversing on your argument. You were right that publication correctness belongs with the reap, and it stays: the never overwrite and never move aside work is above the cut. What moves is only the cross-restart durability, which is a different guarantee and is genuinely half-built. Say the word and I will do the reduction, re-run the full suite against the reduced head, and open the follow-up with your replay repro as its first test. |
|
@qingyun-wu — while your cut decision is open, here is the design for the restart-replay P1, not an implementation. It is the same either way (it is needed in this PR if you say "finish here", and it is the follow-up's first commit if you say "split"), so thinking it through is not wasted — and after this many rounds I would rather have you shoot at a design than at another push. The mechanism already exists; I was building a second one next to it. A live claim already prevents re-dispatch, and I verified the path rather than assuming it: So the replay happens for one reason: startup retires the claim of a task whose handler already ran, and nothing then stops the initial sweep from re-dispatching it. The durable receipt I added knows the task is unsettled; the retirement loop just does not consult it. Proposed change, in the retirement loop only:
Then, with no new state and no new namespace:
What I would want you to attack: the re-acquire is two operations, so a watcher dying between them leaves the task claim-less with a receipt still present. The next startup would then re-dispatch it — the current bug, but narrowed to a crash inside a two-line window rather than every restart. If that is not good enough, the alternative is a durable "handler ran" marker consulted by the sweep, which is a new namespace and a new leak mode, and I would rather not add one if the claim can carry it. Not implementing until you have said where it goes. |
|
No implementation to verify here (design proposal, not a commit) — but spot-checked the three cited control-flow claims against current source, since that's the part any reader can check without waiting on a decision:
So the diagnosis (retirement is what strips ownership from a claim whose receipt says the handler already ran, and nothing currently reconnects the two) is accurate. On the proposed fix itself — retire-then-immediately-reacquire using the existing atomic |
|
@qingyun-wu @bassilkhilo-ag2 — taking the scope call, since you both explicitly left it to me. I'm splitting the durable restart mechanism out of this PR, which is the second of the two resolutions qingyun named. Why that and not "finish it here." I measured the separability rather than guessing: The durability mechanism is a small, well-localised graft on a PR whose stated concern is "a dead handler worker must not hold its claim." That is two concerns in one diff, and CONTRIBUTING wants one. The part that decided it: the replay bug is PRE-EXISTING, not introduced here. I checked So today's What stays here is the titular fix plus the What I owe on the split, stated so it isn't a promise that evaporates: without bassil — on your point about retire-then-immediately-reacquire via the existing atomic I'll push the split shortly. Say so now if either of you would rather I finish it here instead — I'd rather redo the plan than the code. |
The durable UNSETTLED_DIR mechanism made this two concerns in one diff, and it was incomplete: startup retires a stale claim and the initial sweep has no marker to consult, so a fresh watcher could re-dispatch an already-run handler. That replay path predates this PR — main retires unconditionally and has no such marker — so removing the half-built mechanism regresses nothing and stops it from LOOKING closed. A publish that cannot take ownership now holds the claim instead of writing a receipt: strictly safer than clobbering an in-flight result, and not a replay fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — split pushed at The split fell exactly along the seam, and the suite proved it rather than my say-so. After deleting the mechanism I ran the PR's own suite before touching any test — 7 failures, and every one was a durability assertion: Every assertion for the concerns that stay here passed untouched — Then I moved the three scenarios that own those assertions out with the mechanism. Final state: What the retained behaviour now is, precisely: a What the follow-up owes, so this isn't a promise that evaporates: the durable marker plus the initial-sweep consult, with your two repros as its acceptance tests — placeholder-intact-across-restart, and placeholder-removed-before-restart. Both must go red→green there. @bassilkhilo-ag2's retire-then-reacquire-via- Ready for re-review. It's |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head e21bc336. This head removes the incomplete durable unsettled-retry mechanism that could replay a must-handle task after restart, while preserving the dead-worker slot-reaping fix. That appropriately narrows this PR instead of shipping partial cross-restart semantics. Hosted clean-install and diff-coverage checks are green. Approved.
Reviewed by Qingyun's Personal Codex.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed e21bc336. Fifteen review rounds have already been spent here, so I tried to add only what was not yet on the record: an independent control arm, one guard I could not convince myself of by reading, and one thing I think is still open.
Approving — and note this gives the PR its second distinct approver, which is what it was actually short of.
Control arm, reproduced here
Source-only revert to origin/main, your test kept:
FAIL a task arriving after both workers died still gets dispatched
— pending={'task-ccc.txt'} running={'task-aaa.txt', 'task-bbb.txt'}
FAIL prefix-colliding archive does NOT count as this task's result
FAIL a whitespace-only ARCHIVED result does NOT suppress the failure — exists=False
at HEAD: PASS — dead workers free their slot; the reap publishes unless an exact result exists
Three, not one. The two handler_result_exists cases are doing real work: a prefix collision or a whitespace-only archived body would each have suppressed a failure that needed publishing, and neither is reachable from the [ -f "$result" ] the function used to have.
I mirrored your test-safety discipline rather than taking it on faith — recorded this host's live watcher before and after every run via _watcher_trees():
BEFORE: {'69353': {'69353'}}
AFTER: {'69353': {'69353'}}
Same pid, same tree. The stub-fswatch-plus-start_new_session approach does stay inside its own workspace.
The DRAIN_ACTIVE guard — checked, and it holds
A re-entrancy flag that fails to clear would silently re-create the exact permanent-stall class this PR fixes, so I did not want to accept it by reading the happy path. Two ways it could leak, both closed:
set -ewould abandon the function mid-body on any non-zero command, leaving the flag set forever. The script setsset -uonly (line 21), so no.- An early
returnafter the assignment. All four returns (DRAIN_ACTIVEguard,DISPATCH_DIR/shutting-down,acquire_dispatch_lock, the post-lock shutting-down re-check) precedeDRAIN_ACTIVE=1, and nothing between it and the trailingDRAIN_ACTIVE=""returns.
Your "redundant regardless" claim also holds as stated: the outer frame's while runs after the reap loop, so anything the suppressed nested call would have dispatched is dispatched before the outer frame returns.
⚠ Still open: kill -0 answers a different question than the one being asked
worker_pid="$(cat "$DISPATCH_DIR/workers/$filename" 2>/dev/null)"
if [ -n "$worker_pid" ] && kill -0 "$worker_pid" 2>/dev/null; thenkill -0 says some process currently has this number and I may signal it — not my worker is alive. A worker that died and had its pid reissued to any other process of the same user reads as alive, so its marker is never reaped and the slot is retired permanently. That is bug #2866 again, narrowed: rarer, identical shape, and just as silent.
This repo already has the tri-state answer to exactly this question, and this script already sources it:
src/watch-tasks-stream.sh:48 source "$__SCRIPT_DIR/watcher_sentinel.sh"
src/watch-tasks-stream.sh:417 # sentinel_pid_wrote_file reads mtime as "when this watcher stamped".
sentinel_pid_wrote_file "$worker_pid" "$DISPATCH_DIR/workers/$filename" asks could this pid have written this receipt — a reissued pid is younger than the receipt it supposedly wrote, so rc 1, so reaped.
And its fail-safe direction is the one you want here, which is why I think it fits rather than merely being available. Its rc 2 (unmeasurable) means "do not act", which here means treat as alive, do not reap. That is the correct conservative side: wrongly keeping a slot costs one stalled slot, while wrongly reaping a live worker publishes a terminal failure over a task still being worked — and by your own §1 that reply then strands, because the id delivers once.
Why this is not a blocker. main has no liveness check at all — it counts files — so this PR is strictly better on every input, and pid reuse is a narrower instance of the defect it fixes rather than something it introduces. It is a follow-up, and the helper being three lines away in a file you already source makes it a cheap one. I have not measured reuse frequency on a long-lived watcher and am not claiming it is common.
One thing I liked enough to name
publish_terminal_failure returning tri-state, with claim_settled holding the claim instead of releasing it when the publish did not land, is the right shape. The old || true released a claim whose result was never written — a task recorded as settled with nothing delivered, which is the worst of the available failures because every probe reads it as done.
APPROVE.
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 28242029d19e462585b4f2dc19412f8662e5d88a.
Ready to merge from this review’s code perspective. This update is a main-only merge with no conflict-resolution delta; the topic patch retains the same stable patch ID (55d5b27c) as the previously approved head. The activated two-file patch still frees dead worker slots without reintroducing the removed partial cross-restart retry mechanism.
Exact-head verification passed:
- dead-worker/reap production suite
- result-readiness contract (12/12)
- local-task-protocol golden suite
- watcher trap-exit and shell syntax
- diff hygiene and repository review checks
- hosted diff coverage is green
Worst-case disruption remains a false dead-worker reap publishing a terminal failure while live work continues. The implementation mitigates that conservatively by preserving unready destinations and claims, and the focused suite covers zero-byte/whitespace destinations, archived-result identity, claim settlement, and a result landing during reap.
This approval does not replace the repository merge gate: required CI/CLA and two current formal approvals must still be green.
Reviewed by Qingyun Personal Codex.
…nputs enumerated (#2871) Two PRs on 2026-08-13 took five and six review rounds each. Every round found a real defect, and every one was the input one step to the side of the one the previous fix tested. Three reviewers independently named the shape before anyone wrote it down, which is the signal it belongs in the review criteria rather than in a single PR thread. #2867 rebuilt "does a result exist?" four times: a hand-rolled archive glob that a prefix collision satisfied, `-s` that accepted whitespace the shared result_ready contract rejects, an overwrite that destroyed a late answer, and a check-then-mv that relocated one out of the delivery path. The first two were already answered correctly by local_task_protocol and result_ready. #2868 rebuilt "is this session routed?" six times, ending in a diagnosis line that echoed URL credentials into a shared self-diagnose bundle. The lesson is stated so a reviewer can apply it prospectively — list the values that reach the same decision, exercise each, and ask the shared owner rather than restating its knowledge — plus the tell that you are already in the pattern: the suite grows by one case per round and each case is the previous one with a single field changed. Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`watch-tasks-stream-dead-worker-reap` landed on main (sonichi#2867) after this branch forked, asserting the late answer is byte-identical at the delivery path. This branch stamps ordinary `task-*` results in `read_ready_result`, so the body on disk is the answer behind a `[task YYYYMMDD-NNN]` prefix and the equality fails on the merge even though both sides are correct alone. The check's stated intent is that the answer stays AT the delivery path rather than a hidden sibling; that survives stamping, the byte-equality does not. Uses the same `_STAMP` / `_unstamped()` pair the two gateway suites already carry for this, rather than a third spelling. Third stale integration contract of this kind on this PR; the first two were fixed at 7cbda98. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfMkAHum26sUMoxf4cbHut
Fixes #2866.
The defect
drain_dispatch_queuecounted files inrunning/, not live workers:The only thing that removes a marker is
finish_handler_task, reached via aHANDLER_DONEevent on the runtime FIFO. A worker killed or crashed before it emits that event leaves the marker orphaned and retires the slot permanently. Two such deaths stall the required-handler lane for the life of the watcher — every later Team-tier task queues inpending/and is never answered, whilehealth-checkstill reportstask-watcher ok. Silent by construction.Measured live before writing anything: 2
running/markers, worker pids 58226 and 36241 both dead, one task pending 10+ minutes, zero dispatches. Both "running" tasks had in fact completed and delivered.The fix
Count live workers using the PID receipts already written to
$DISPATCH_DIR/workers/<filename>, and reap a dead worker's marker so the slot returns.Two things the reap had to get right — both found by the test, not by reading the code. I proposed a simpler version on the issue and it was wrong on both counts:
1. rc must not be a blanket
1.publish_terminal_failureguards only the live results file:Delivery consumes that file. So reaping a worker that had already answered would write "I could not safely process this Team-tier task" for a task that succeeded — and since the id delivers once, that reply can never be sent and strands as an orphan result. That exact shape was observed on this host (a second result written at 00:05 for a task delivered at 00:03, later surfacing as
orphaned-results).handler_result_existstherefore checksresults/archive/**as well, and the reap only publishes a failure when nothing was ever produced.2. Reaping from inside the drain deadlocked.
finish_handler_taskends by callingdrain_dispatch_queue, andacquire_dispatch_lockis amkdirspinlock with no timeout:So the nested call spins forever against the lock the outer frame already holds. I had claimed on the issue that this call was safe because
finish_handler_taskdoes not take the lock directly; the test showed otherwise — one marker reaped, then the watcher hung. ADRAIN_ACTIVEre-entrancy guard makes the nested call a no-op. It is redundant regardless: the outer frame dispatches everything pending before it returns.Evidence — the test discriminates
Real-process reproduction.
watch-tasks-stream.shhas noBASH_SOURCEguard, so its functions cannot be sourced in isolation; the test drives a real watcher with a stubfswatch(atail -fon a feed file, so it both holds stdout open and emits on demand — the stall only becomes observable when a new task arrives to trigger a drain) and a stub handler whose--probeexits 4.It fills both slots via the startup sweep,
SIGKILLs both workers so neither emitsHANDLER_DONE, then delivers a third task:Regression + hygiene at HEAD:
Safety of the test itself
Following the discipline documented in
tests/watch-tasks-stream-sentinel-ownership.test.py: every watcher is started withstart_new_session=True(cleanup()ends inkill 0, which would otherwise signal the test's own group), the workspace andTMPDIRare per-run temp dirs, and only pids the test recorded are killed — neverpkill -f watch-tasks-stream, which matches the operator's live watcher. I verified the host's real watcher (pid 89762) was present and unchanged before and after each run.Scope
One function plus a small helper, and a new test. No change to the handler contract, the claim protocol, or the
HANDLER_DONEpath — a worker that emits normally still settles exactly as before; the reap only touches markers whose worker is already gone.