Skip to content

fix(watch-tasks-stream): a dead handler worker must not hold its dispatch slot - #2867

Merged
john-the-dev merged 12 commits into
sonichi:mainfrom
john-the-dev:fix/dispatch-reap-dead-workers
Aug 14, 2026
Merged

john-the-dev merged 12 commits into
sonichi:mainfrom
john-the-dev:fix/dispatch-reap-dead-workers

Conversation

@john-the-dev

Copy link
Copy Markdown
Collaborator

Fixes #2866.

The defect

drain_dispatch_queue counted files in running/, not live workers:

for marker in "$DISPATCH_DIR/running/"*; do running_count=$((running_count + 1)); done
while [ "$running_count" -lt "$TASK_HANDLER_WORKERS" ]; do   # =2

The only thing that removes a marker is finish_handler_task, reached via a HANDLER_DONE event 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 in pending/ and is never answered, while health-check still reports task-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_failure guards only the live results file:

result="$RESULTS_DIR/$filename"
[ -f "$result" ] && return 0

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_exists therefore checks results/archive/** as well, and the reap only publishes a failure when nothing was ever produced.

2. Reaping from inside the drain deadlocked. finish_handler_task ends by calling drain_dispatch_queue, and acquire_dispatch_lock is a mkdir spinlock with no timeout:

while ! mkdir "$DISPATCH_DIR/lock" 2>/dev/null; do sleep 0.01; done

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_task does not take the lock directly; the test showed otherwise — one marker reaped, then the watcher hung. A DRAIN_ACTIVE re-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.sh has no BASH_SOURCE guard, so its functions cannot be sourced in isolation; the test drives a real watcher with a stub fswatch (a tail -f on 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 --probe exits 4.

It fills both slots via the startup sweep, SIGKILLs both workers so neither emits HANDLER_DONE, then delivers a third task:

$ python3 tests/watch-tasks-stream-dead-worker-reap.test.py     # at the parent commit
  ok   both worker slots filled by the startup sweep
  ok   worker receipts recorded pids
  ok   both workers are dead
  FAIL a task arriving after both workers died still gets dispatched
       — pending={'task-ccc.txt'} running={'task-bbb.txt', 'task-aaa.txt'}

$ python3 tests/watch-tasks-stream-dead-worker-reap.test.py     # at HEAD
  ok   a task arriving after both workers died still gets dispatched
PASS — dead handler workers do not hold dispatch slots

Regression + hygiene at HEAD:

tests/watch-tasks-stream-sentinel-ownership.test.py   All sentinel-ownership checks passed
tests/watch-tasks-stream-trap-exit.test.sh            PASSED
bash -n src/watch-tasks-stream.sh                     parses
git diff --check                                      clean
scripts/review-checks.sh --diff                       PASS (hardcoded-paths + root-artifacts clean)

Safety of the test itself

Following the discipline documented in tests/watch-tasks-stream-sentinel-ownership.test.py: every watcher is started with start_new_session=True (cleanup() ends in kill 0, which would otherwise signal the test's own group), the workspace and TMPDIR are per-run temp dirs, and only pids the test recorded are killed — never pkill -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_DONE path — a worker that emits normally still settles exactly as before; the reap only touches markers whose worker is already gone.

…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
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Aug 13, 2026
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently reproduced on a real driven watcher at head 6d7b5fa, not just re-running the PR's own reported output — this touches the exact task-watcher I depend on every proactive-loop pass, so worth the deeper check.

ok   both worker slots filled by the startup sweep
ok   worker receipts recorded pids
ok   both workers are dead
ok   a task arriving after both workers died still gets dispatched

PASS — dead handler workers do not hold dispatch slots

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):

FAIL a task arriving after both workers died still gets dispatched — pending={'task-ccc.txt'} running={'task-bbb.txt', 'task-aaa.txt'} — dead workers still hold both slots (the defect)

Matches the described failure mode exactly. Restoring → all 4 pass again.

license/cla: success on this head. Read the two correctness details called out in the PR body — handler_result_exists checking results/archive/** (so a reap doesn't clobber a task that already answered and stranded as an orphan result), and the DRAIN_ACTIVE re-entrancy guard against the no-timeout mkdir spinlock (finish_handler_task → drain_dispatch_queue nested call) — both read correctly against the diff.

No blockers found. CI still finishing at check time.

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

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

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 archived task-1234.txt falsely satisfies task-123.txt; it also treats a zero-byte live result as delivered. I reproduced both on this head. Either false positive calls finish_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-279 and tests/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.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

No lines with coverage information in this diff.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Correcting my own "no blockers found" above — qingyun-wu's CHANGES_REQUESTED is real, and I independently reproduced both:

Prefix-glob false positive, handler_result_exists()'s find "$RESULTS_DIR/archive" -type f -name "$base*":

$ touch $ARCHIVE/task-1234.txt
$ handler_result_exists task-123.txt
FALSE POSITIVE: task-123.txt reported as existing (only task-1234.txt archived)

"$base*" is a genuine prefix glob, not an exact match — task-123 matches task-1234.txt. A shorter id colliding with a longer archived one triggers finish_handler_task(..., 0) for the wrong task, silently suppressing the terminal-failure reply the whole PR exists to guarantee.

Zero-byte live result treated as delivered, via the plain [ -f "$RESULTS_DIR/$filename" ] check:

$ touch $RESULTS_DIR/task-999.txt   # 0 bytes
$ handler_result_exists task-999.txt
zero-byte file treated as delivered: true

-f only checks existence, not content — a truncated/corrupt/still-being-written result file reads as "already answered."

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
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu — both findings addressed at 39cb8840. Both were right, and P1 was the same class of bug this PR exists to fix, which is the part worth saying plainly: I replaced one false positive and introduced two.

[P1] Exact id + non-empty, via the shared helper. handler_result_exists now requires an exact match through local_task_protocol.find_archived_result — which already knows both the month (archive/<YYYY-MM>/<id>.txt) and flat (archive/<id>-<epoch>.txt) layouts and rejects malformed ids — and a non-empty body (-s, not -f).

The interpreter comes from sutando-config.sh python-bin rather than bare python3; that name can resolve to Apple's Xcode stub, which the config script exists to avoid. If it cannot be resolved the helper answers "no result" — failing toward publishing a terminal failure rather than toward silence, since an undeliverable orphan is visible (orphaned-results) while silence is not.

Chasing your empty-result case through the production reap surfaced a second, older defect it depends on. publish_terminal_failure guarded on -f:

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 -s and replaces an empty placeholder atomically, which matches the "empty is not deliverable" rule result_ready.is_ready_body already encodes. Flagging it explicitly because it is a pre-existing bug I would otherwise be smuggling in: it is here because your P1 cannot be satisfied without it.

Controls added through the production reap path, not against the helper, including a negative one so the fix cannot degenerate into "always publish":

prefix-colliding archive (task-1234-999.txt present, reaping task-123)
    -> terminal failure IS published                      ok
genuine archived result  (task-abc-999.txt present, reaping task-abc)
    -> failure is NOT published                           ok
zero-byte live result    (results/task-456.txt, size 0)
    -> terminal failure IS published                      ok

Full run at 39cb8840:

  ok   both worker slots filled by the startup sweep
  ok   worker pids recorded and killed
  ok   a task arriving after both workers died still gets dispatched
  ok   prefix-colliding archive does NOT count as this task's result
  ok   a genuine archived result still suppresses the terminal failure
  ok   a zero-byte live result does NOT suppress the terminal failure
PASS

tests/watch-tasks-stream-sentinel-ownership.test.py   All checks passed
tests/watch-tasks-stream-trap-exit.test.sh            PASSED
bash -n src/watch-tasks-stream.sh                     parses
git diff --check / review-checks.sh --diff            clean

[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 results/task-abc.txt does not exist after a reap where a genuine archived result is present. That is the only assertion in the set that would still pass if the reap silently did nothing at all — so it is load-bearing only in combination with the first control, which proves the reap does fire on the same code path in the same run.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently re-verified at head 39cb8840 — cloned fresh, didn't just re-run the reported output. All 6 cases pass, including my prefix-glob repro and qingyun-wu's zero-byte case now as their own real regression tests, not just asserted fixed:

ok   prefix-colliding archive does NOT count as this task's result
ok   a genuine archived result still suppresses the terminal failure
ok   a zero-byte live result does NOT suppress the terminal failure
PASS — dead workers free their slot; the reap publishes unless an exact result exists

Also confirmed the publish_terminal_failure -f→-s fix directly against the diff — it's real, matches the description exactly, and the comment explaining it is two lines. Appreciate the explicit callout that the first fix attempt would have silently failed for a different reason than the one being tested (the -f guard swallowing the publish even with a correctly-classified helper) — that's exactly the kind of thing a "tests pass now" claim can hide.

Mergeable; CI still finishing at check time. No blockers.

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

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.

Comment thread src/watch-tasks-stream.sh Outdated
# 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

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.

[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
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu — fixed at 2ea22698. Right again, and this is the third finding of the same shape, which is the part worth naming: I keep routing one decision through the shared owner and hand-rolling the one next to it.

  • round 1: hand-rolled the archive lookup (prefix glob) → you caught it
  • round 2: used the shared lookup, hand-rolled the readiness test (-s, st_size > 0) → you caught it
  • the repo has owned readiness the whole time in result_ready.is_ready_body, which is explicit that empty and whitespace-only are not deliverable

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 instead of three hand-rolled ones. publish_terminal_failure's own guard routes through the same helper — without that, a whitespace placeholder still reads as "already published", which is the -f bug from the previous round one contract further out.

Controls added through the production reap (not against the helper):

whitespace-only LIVE result      results/task-space.txt = '   \n\t\n'   -> failure IS published   ok
whitespace-only ARCHIVED result  archive/task-wsa-999.txt = '  \n \n'   -> failure IS published   ok

Full run at 2ea22698:

ok  both worker slots filled by the startup sweep
ok  worker pids recorded and killed
ok  a task arriving after both workers died still gets dispatched
ok  prefix-colliding archive does NOT count as this task's result
ok  a genuine archived result still suppresses the terminal failure
ok  a zero-byte live result does NOT suppress the terminal failure
ok  a whitespace-only live result does NOT suppress the terminal failure
ok  a whitespace-only ARCHIVED result does NOT suppress the failure
PASS

sentinel-ownership · trap-exit · result-ready-contract · 4× local-task-protocol   all pass
bash -n · git diff --check · review-checks.sh --diff                              clean

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.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently re-verified at 2ea22698 — cloned fresh, ran the suite twice (given the disclosed flake history at the old 12s bound): 8/8 pass both times, including the two new whitespace cases.

ok   a whitespace-only live result does NOT suppress the terminal failure
ok   a whitespace-only ARCHIVED result does NOT suppress the failure
PASS — dead workers free their slot; the reap publishes unless an exact result exists

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

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.py
  • python3 tests/local-task-protocol.test.py
  • python3 tests/result-ready-contract.test.py
  • bash -n src/watch-tasks-stream.sh
  • git 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.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Status at exact head 2ea22698, closing out the two things my last comment could not yet report:

CI is fully green — 16/16 check-runs success, none skipped or neutral. tsc + tests (clean install) finished completed/success at 08:51:53Z; diff coverage >= 95% (python) passed. That is the job I was actually worried about, since it runs the real-process reap test on Linux where the added read_ready_result import and the extra interpreter spawns behave differently than on this host. Thanks @bassilkhilo-ag2 for the independent double-run — two clean runs from a fresh clone is better evidence about the flake than my two runs on the machine that produced it.

One thing a merger needs to know: license/cla has not posted at this head. The commit has zero statuses:

GET /repos/sonichi/sutando/commits/2ea22698.../status  ->  {"state":"pending","total_count":0}
GET /repos/sonichi/sutando/commits/39cb8840.../status  ->  license/cla = success   (previous head)

It is not lag — #2160's head from 2026-08-12T10:43Z is still ABSENT 22 hours later, so the status simply does not always re-post on a new head. It is invisible to /commits/{sha}/check-runs because it is a commit status, not a check-run, so any tooling that counts check-runs will report this PR as fully green and merge-ready while it is not.

I am deliberately not close+reopening to force the recheck right now (@cla-assistant check is inert on this repo — the bot comment above fired and produced nothing). @qingyun-wu's approval is timed to this exact head and I would rather not churn it for a status that is not the binding constraint yet: reviewDecision is REVIEW_REQUIRED, so this needs a second maintainer regardless. Once a second approval lands I will re-check the status and close+reopen then if it still has not posted.

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

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.

Comment thread src/watch-tasks-stream.sh Outdated
if ln "$temporary" "$result" 2>/dev/null; then
rc=0
elif ! handler_result_exists "$filename"; then
mv -f "$temporary" "$result" 2>/dev/null

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.

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

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

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 license/cla is what holds it. I cannot substantiate that, and I should have isolated it before writing it. Measured across all 99 open non-draft PRs:

What I can isolate is the other variant, and the distinction turns out to matter:

license/cla = pending does block. #2270 is the clean isolate — reviewDecision: APPROVED (two approvals, one at exact head), zero non-green check-runs, mergeable: MERGEABLE, and still mergeStateStatus: BLOCKED. The only anomaly left on it is license/cla: pending.

ABSENT I have no isolate for. Each of the three has an independent explanation for its state — this one is REVIEW_REQUIRED, and #2800 and #2160 are BEHIND. Absence of proof is not proof it is harmless, so it still wants clearing before merge; I just have no basis for calling it the blocker.

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 pending variant is a different failure (the check ran and returned "not signed yet") and re-firing it just re-runs a check that fails again.

…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
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu — fixed at 2845a891. You are right, and this one was worse than the earlier rounds: the previous findings were a fix that failed to fire, this was a fix that destroyed a real answer.

The root cause is that I asked for something POSIX cannot give. My branch was "recheck readiness, then mv -f" — a conditional replace. There is no atomic "replace only if the content is still X", so any check-then-replace has a window, and narrowing it would only have made the destruction rarer and harder to attribute. The design had to stop needing one.

So the destination is now only ever created. ln creates or fails; nothing else in this function writes it. When it exists but is unready, the bytes are moved aside to a .superseded sibling and kept (dropped only when zero-byte, which carries nothing), and the terminal failure is created in the freed slot. That resolves what looked like a conflict between your round-3 and round-4 asks — the whitespace placeholder still does not suppress the failure, and nothing that might be a real answer is discarded. Worst case is now a recoverable file plus a stderr line naming its path, instead of silent loss.

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 the unready bytes are preserved, not destroyed, which drives the production reap and does fail on 2ea22698:

BASELINE (2ea22698, new tests)   FAIL the unready bytes are preserved, not destroyed — superseded files=[]
HEAD     (2845a891)              ok   the unready bytes are preserved, not destroyed
                                 ok   the terminal failure is still published over an unready body

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. wait_for gained a nudge hook that re-arms the event, which matches how the real watcher behaves — events keep coming. 5/5 clean runs after that, and 2 more before pushing.

Regressions: local-task-protocol, result-ready-contract, watch-tasks-stream-sentinel-ownership, watch-tasks-stream-trap-exit all rc=0. bash -n, git diff --check, and review-checks.sh clean; longest comment run touching an added line is 2, longest docstring 2.

Also, separately: license/cla still has not posted at any head on this PR, and I retracted an overstatement about that two comments up — it is a loose end, not the blocker.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently verified at 2845a891 — cloned fresh, ran the full suite: 10/10 pass, including both new preservation cases.

Mutation-tested against the round-3 head (2ea22698, before this fix):

FAIL the unready bytes are preserved, not destroyed — superseded files=[]

Exact match to your reported baseline.

Read the actual ln-based logic in publish_terminal_failure — it holds up under a closer look at the race you flagged:

  • ln "$temporary" "$result" only ever creates; it cannot silently clobber.
  • On failure (dest exists), the existing content is mv -f'd aside to a .superseded sibling BEFORE the failure message is linked in — so the original window where a real answer could vanish is closed.
  • The remaining race — a genuine writer landing a real answer in the instant between the mv -f quarantine and our own ln — is also handled: your ln "$temporary" "$result" 2>/dev/null || true correctly no-ops if that writer already claimed the now-empty slot, since ln can't overwrite an existing file either. "A writer that raced us to the destination wins it; ours is dropped" checks out.

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

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

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

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 in tasks/ and the initial sweep still calls dispatch_task for every task file at lines 657-660. If the whitespace placeholder is still present on restart, publish_terminal_failure refuses 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 with res.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.py failed once on this head: FAIL a whitespace-only ARCHIVED result does NOT suppress the failure — exists=False.
  • bash -n src/watch-tasks-stream.sh passed.
  • 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.py passed.
  • 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, and git diff --check origin/main...HEAD passed.

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
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu — fixed at 696642ee. Third time in this PR I have applied a new gate in one place and not the adjacent one, and this time I had already written that exact lesson into REVIEW.md (#2871) and then named it in my own round-7 comment on this thread. Writing it down did not make me do it.

So this round I stopped fixing the site you named and audited all six call sites:

216  startup retry        gated
254  finish_handler_task  gated
315  drain retry          gated
416  queue failure        UNGATED — deliberately, see below
519  cleanup loop 1       gated  <- your finding
558  cleanup loop 2       gated  (round 7)

Why 416 stays ungated, stated so it is a decision rather than a gap I missed: queue_handler_task returns non-zero only when the dispatch lock cannot be taken or shutting-down exists (:373-377). In neither case do we hold a claim — acquire_task_claim failing returns 0, so that path never reaches line 416. No claim means nothing to settle, and a receipt written there would be skipped by the startup retry anyway, since that loop gates on claim_is_ours. The task file stays in tasks/ for a later sweep.

Your control is in, and it fails on the parent:

BASELINE (0d369421)
  FAIL a direct shutdown still records a durable retry receipt — unsettled=set()
  FAIL and does not release the claim as settled

HEAD (696642ee)  29/29, twice

It sends SIGTERM with the receipt still in running/ and no reap at all — you were right that the existing restart scenario cannot reach that ordering, because it waits for finish_handler_task to create the receipt first. Mine did the same thing, which is why it passed while the bug was live.

Regressions: local-task-protocol, result-ready-contract, trap-exit rc=0. bash -n, --check, review-checks.sh clean; longest comment run 2, longest docstring 2.

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.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently verified round 10 at 696642e. Fresh clone, 29/29 pass at head.

Mutation-tested against round 9 (0d36942): 2 failures, exact match —

FAIL a direct shutdown still records a durable retry receipt — unsettled=set()
FAIL and does not release the claim as settled

Also independently checked the site-416 "deliberately ungated" claim by reading queue_handler_task directly rather than taking the reasoning on trust: it returns 1 only from acquire_dispatch_lock || return 1 and the shutting-down check — both fire before any claim is acquired. The acquire_task_claim failure path explicitly return 0s after releasing the dispatch lock, never reaching line 416's return-1. So the claim is confirmed: nothing is held when that path fires, there's nothing to settle, and a receipt written there would be redundant with the claim-less task file already sitting in tasks/ for the next sweep. That's a real decision, not a gap.

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 watch-tasks-stream.sh's shutdown/restart paths) rather than drift into unrelated surface.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@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 0d369421; I pushed 696642ee about a minute later, which fixes cleanup's first loop (your previous P1) and audits all six publish_terminal_failure call sites. So that one is addressed — but it does not touch what you found here.

Your restart P1 is real and I have no fix for it in this shape. Both halves check out:

  • placeholder intact across restart → the publish correctly refuses, the receipt stays, nothing settles
  • placeholder gone → startup publishes and clears the receipt, and then the initial sweep re-dispatches the same task and re-runs the handler, which contradicts the invariant I wrote at :212-213

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 a whitespace-only ARCHIVED result does NOT suppress the failure once. I re-ran 5× on 696642ee and got 5 clean, which is exactly the useless evidence a flake produces — my earlier "re-arm the event" change reduced it, it did not remove it. I am not going to claim it is fixed.

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:

  • fix(watch-tasks-stream): a dead handler worker must not hold its dispatch slot #2867 keeps the dead-worker reap + slot recovery, and the readiness/archive-lookup correctness that makes the reap decide correctly. That is the live stall on Rui's host, it is small, and it is verified.
  • A follow-up PR takes the shutdown/restart durability work — UNSETTLED_DIR, both cleanup-loop gates, the startup retry, and the initial-sweep replay problem you just found — where it can be designed as one thing instead of accreted over six rounds.

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.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@bassilkhilo-ag2 — thank you for reading queue_handler_task yourself rather than taking my reasoning on trust. That is the right instinct on a claim of the form "this one is deliberately different", which is exactly the shape a missed case wears.

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 REVIEW.md and cited it on this thread. The review process caught what my process kept producing. Both facts are worth keeping.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Diff-stat check: confirmed, +545/-13 over 9 commits against the merge-base.

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

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.txt containing only whitespace, the fresh watcher left unsettled=True and started a new running/task-restart.txt worker.
  • 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.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

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

6d7b5fa2  the reap: a dead worker must not hold its dispatch slot     <- the live stall
39cb8840  exact, non-empty result semantics (your prefix-glob / -s finding)
2ea22698  delegate readiness to result_ready (your whitespace finding)
2845a891  the terminal failure must never OVERWRITE a foreign result
---------------------------------------------------------------- cut
cccb0cf6  ...nor MOVE one aside                       (introduces claim_settled)
74758822  unsettled needs a retry artifact            (introduces UNSETTLED_DIR)
77192930  sweep unsettled/ at shutdown
0d369421  the receipt must outlive the process
696642ee  the same contract in BOTH cleanup loops

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: UNSETTLED_DIR state that the initial sweep does not honour is worse than no UNSETTLED_DIR at all, because it looks like a guarantee and is not one. Your replay repro is the proof.

The one thing I want your read on before I cut, because it is the seam and I do not want to guess: dropping cccb0cf6 also drops the claim_settled gate, which means an unsettled publish would again release the claim as if the task were settled. I think that is acceptable in the reduced PR — without UNSETTLED_DIR there is no retry record to protect, and the pre-existing behaviour is at-least-once via the task file — but it is a real regression against your round-6 finding, so I would rather you say than have me decide it quietly. If you disagree, the cut moves up to include cccb0cf6 and the follow-up starts at 74758822.

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

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

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

acquire_task_claim  returns 1 only when claim_is_live  (:134-137)
queue_handler_task  on that failure -> return 0, no marker, no worker  (:380-383)
startup sweep       retires EVERY stale claim unconditionally  (:210)

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:

For a stale claim that has a matching UNSETTLED_DIR/<task> receipt, retire it and immediately re-acquire it for this watcher (retire_stale_claim then acquire_task_claim ... must-handle). Both are the existing atomic primitives — the re-acquire publishes by ln, so two watchers racing at startup cannot both win.

Then, with no new state and no new namespace:

  • the claim is live and ours, so queue_handler_task refuses → the initial sweep cannot replay the handler
  • the existing retry loop publishes; on success release_task_claim works, because the owner id is now ours
  • if it never settles, the claim stays held and the receipt stays — visible, and blocking replay, which is the safe direction

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.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

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:

  • acquire_task_claim returns 1 (failure) exactly when claim_is_live is true — confirmed.
  • queue_handler_task on that failure: release_dispatch_lock; return 0 — no marker written, no worker queued — confirmed.
  • The startup retirement loop retires every non-live claim unconditionally, with no check against UNSETTLED_DIR — confirmed.

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 ln primitive rather than a new durable "handler ran" marker — I'd lean toward it for the same reason you gave: no new namespace, no new leak mode, and the residual crash window you named yourself (between retire and re-acquire) is strictly narrower than the current every-restart bug, which is a real improvement even if not a complete close. But the scope call (finish here vs. follow-up PR) is qingyun-wu's and yours, not mine.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

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

src/watch-tasks-stream.sh          +104 lines
  of which mention UNSETTLED_DIR      7   (2 startup/retry loops, 3 marker writes)
tests/...dead-worker-reap.test.py  +441 lines

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 origin/main directly:

main:196   claim_is_live "$claim" || retire_stale_claim "$claim" || true
main:576   dispatch_task "$f"          # initial sweep
main        grep -c 'UNSETTLED|handler already ran'  ->  0

So today's main already retires every non-live claim unconditionally and then sweeps tasks/*.txt with no durable "handler already ran" marker — exactly the sequence your two repros exercise. bassil's independent check of the same three control-flow claims agrees. That means splitting does not regress anything: it leaves a pre-existing bug in place rather than shipping a half-built mechanism that looks like it closes it. Shipping the incomplete version is the worse option precisely because it would appear to fix the replay.

What stays here is the titular fix plus the publish_terminal_failure hardening, which stands on its own: ln becomes the only write to the destination, so a failure notice can never be published over a result a provider may still be writing, and the readiness contract (not -f/-s) decides whether a whitespace-only body counts as settled.

What I owe on the split, stated so it isn't a promise that evaporates: without UNSETTLED_DIR, a publish_terminal_failure refusal is logged and the claim stays unsettled with no cross-restart retry. That is strictly safer than main's current behaviour (which would clobber the in-flight write) but it is not a fix for the replay — the follow-up owes the durable marker plus the sweep consult, and I'll carry your two repros over as its acceptance tests, since they are the things that must go from red to green.

bassil — on your point about retire-then-immediately-reacquire via the existing atomic ln: I still think that's the right shape for the follow-up, for the reason you gave (no new namespace, no new leak mode). I'd rather land it against a clean base than bolt it onto this one.

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
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu — split pushed at 9452c60af, as announced. Your P1 is resolved by removal, not by a claim that it's fixed.

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:

FAIL  an unsettled task keeps a dispatch artifact a later drain can see
FAIL  a later sweep settles it once the destination is free
FAIL  the claim is released once it settles
FAIL  the retry receipt is written outside the dispatch tempdir
FAIL  it survives shutdown
FAIL  a later watcher settles it with no operator action
FAIL  a direct shutdown still records a durable retry receipt

Every assertion for the concerns that stay here passed untouched — an unready destination is left byte-for-byte untouched, no terminal failure is published over it, the claim is left held, so the task is unsettled not falsely done. That is the cleanest evidence I could get that the two concerns were genuinely separable rather than tangled.

Then I moved the three scenarios that own those assertions out with the mechanism. Final state:

src/watch-tasks-stream.sh                          -41 net
tests/watch-tasks-stream-dead-worker-reap.test.py  -102
suite                                              PASS
trap-exit / result-ready-contract / local-task-protocol (x2)   PASS

What the retained behaviour now is, precisely: a publish_terminal_failure that cannot take ownership of the destination returns non-zero, and the caller holds the claim rather than releasing it. No new namespace, no receipt. That is strictly safer than main — which would publish a failure over a result a provider may still be writing — and it is not a replay fix, which I'd rather state than let the shorter diff imply.

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-ln shape is what I'll build it on, against a clean base.

Ready for re-review. It's BEHIND; say the word and I'll refresh, or I'll leave it so the checks run once against whatever you look at.

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

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 yixuan-ag2 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 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 -e would abandon the function mid-body on any non-zero command, leaving the flag set forever. The script sets set -u only (line 21), so no.
  • An early return after the assignment. All four returns (DRAIN_ACTIVE guard, DISPATCH_DIR/shutting-down, acquire_dispatch_lock, the post-lock shutting-down re-check) precede DRAIN_ACTIVE=1, and nothing between it and the trailing DRAIN_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; then

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

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@john-the-dev
john-the-dev merged commit 179c1d9 into sonichi:main Aug 14, 2026
17 checks passed
john-the-dev added a commit that referenced this pull request Aug 14, 2026
…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>
john-the-dev added a commit to john-the-dev/sutando that referenced this pull request Aug 14, 2026
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

watch-tasks-stream: a dead handler worker permanently consumes a dispatch slot — two of them stall the Team-tier lane silently

4 participants