Skip to content

fix(watch-tasks-stream): close the post-signal promotion window (#2934) - #3025

Merged
john-the-dev merged 2 commits into
sonichi:mainfrom
yixuan-ag2:fix/2934-close-the-promotion-window
Aug 17, 2026
Merged

john-the-dev merged 2 commits into
sonichi:mainfrom
yixuan-ag2:fix/2934-close-the-promotion-window

Conversation

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Implements @sonichi's diagnosis and safety inventory from #2934 — the ordering candidate, not the
parsing one. The credit for the root cause and for establishing that nothing in cleanup depends on
shutting-down being absent is entirely theirs; what I add is the before/after measurement they said
they could not produce.

The window

shutting-down is written at :479, inside fallback_outstanding_handlers, which cleanup calls
after disarming traps, releasing the PID sentinel and killing fswatch. The drain guards (:292,
:294) and the queue guard (:354) all read that file, so every drain between signal delivery and
:479 sees no sentinel and promotes normally. On the Linux failure that is a third worker starting
75 ms into teardown against TASK_HANDLER_WORKERS=2.

The change

 cleanup() {
   [ "${CLEANING_UP:-0}" -eq 0 ] || return
   CLEANING_UP=1
+  # FIRST, before any release/kill/sweep work: the drain and queue guards read
+  # this file, so anything they do before it exists can still promote a worker.
+  if [ -n "${DISPATCH_DIR:-}" ] && [ -d "$DISPATCH_DIR" ]; then
+    : > "$DISPATCH_DIR/shutting-down"
+  fi

Same guard fallback_outstanding_handlers already applies at :478. The :479 write stays — an
idempotent truncate, per the inventory.

Before / after — measured, n=8 each

Time from SIGTERM delivery to shutting-down existing, polled at 1 ms from outside the watcher:

with fix   n=8  min 1.8  median 1.8  max 2.0    ms
without    n=8  min 7.8  median 17.3 max 20.1   ms
overlap: NONE — max(with) < min(without)

The "without" set is visibly bimodal (7.8 twice, 15.8–20.1 six times), so the median is a
property of this host's scheduling, not a constant — I am quoting the range, not a single number.
The point is the separation, and that 7.8–20.1 ms is the same order as the 75 ms window measured on
the failing Linux run.

What this is NOT: a race reproduction

Stated plainly because it is the weakest part. The race does not fire on macOS (100 clean trials on
test_shutdown_falls_back_without_surviving_workers per #2934), and I have no Linux vantage point
here — docker/colima/podman/lima/orb are all absent. So the measurement above shows the window
narrowing
, not a failing-to-passing test. The mutation control lives in the Linux CI lane.

Two test designs I tried and abandoned, so the next person doesn't repeat them:

  1. Fake fswatch whose TERM handler checks for the sentinel. Fails when the test signals the
    process group — fswatch receives the signal directly at delivery time, before cleanup has done
    anything, so it reports absent on fixed and unfixed alike.
  2. Same probe, signalling only the watcher (os.kill(pid) not killpg). Now reports present
    on both, because the fswatch child ends up observing the end-of-cleanup group TERM — which is
    after :479 — rather than cleanup's targeted kill.

Both are the probe changing what it measures. The latency poll above is the only seam I found that
needs no cooperation from inside the script. A timing-bound assertion could be written from it, but
#2931 already tried widening a deadline and the flake survived, so I did not add one — a bound would
be green on both sides on any host where the race doesn't fire, i.e. exactly the hosts developers use.

Regression checks

tests/task-workstream-session-worker.test.py          OK
tests/watch-tasks-stream-dead-worker-reap.test.py     OK
tests/watch-tasks-stream-sentinel-ownership.test.py   OK

$ git diff | bash scripts/review-checks.sh
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

Companion, independent of this one: #3024 instruments the hang leg of #2934, which is a
different failure from the <= 2 breach this fixes. Neither depends on the other.

🤖 Generated with Claude Code

https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm

…chi#2934)

`shutting-down` was written at :479, inside fallback_outstanding_handlers, so
every drain between signal delivery and that point saw no sentinel and
promoted normally — the third handler call against a bound of 2. Hoisting the
write to cleanup's first act closes the window; the :479 write stays as an
idempotent truncate.

Measured window from SIGTERM to sentinel: 7.8-20.1ms before, 1.8-2.0ms after
(n=8 each, no overlap). Diagnosis and safety inventory: sonichi on sonichi#2934.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Approving at d9040e52. Five lines, and the safety argument is the whole review — so I traced it rather than taking the body's word for it.

The window is real. shutting-down is written at :479, inside fallback_outstanding_handlers, which cleanup reaches only after trap - EXIT, sentinel_release_if_owner, and kill -TERM "$FSWATCH_PID". The three guards that consult it (:292, :294, :354) all read a file that does not exist yet, so a drain landing in that span promotes normally.

The load-bearing claim — that nothing in cleanup depends on the sentinel being absent — holds, and it is the one that could have made this unsafe. There is exactly one removal in the script, :592, and it sits inside if [ "$cleanup_ready" -eq 1 ] immediately before rmdir "$DISPATCH_DIR". So the sentinel is only ever unlinked as the last step of deleting the dispatch directory outright, not as a state transition anything reads. Writing it earlier cannot race that: :592 still removes it before the rmdir, so the directory is empty when the rmdir runs. And when cleanup_ready=0 the file is deliberately left behind together with the directory, which the new write does not change.

The guard on the new write (-n "$DISPATCH_DIR" and -d) is the same pair fallback_outstanding_handlers returns on at :478, so the two cannot disagree about whether the directory exists — an early write that the later function then declined to clean up would be the obvious way to get this wrong.

No regression in the teardown tests, run at this head:

watch-tasks-stream-shutdown-emit.test.sh        rc=0
watch-tasks-stream-trap-exit.test.sh            rc=0
watch-tasks-stream-sentinel-ownership.test.py   rc=0

The gap, and I want to be consistent about it rather than reflexive. No test references shutting-down anywhere in tests/ — not before this PR and not after, so coverage is unchanged rather than reduced. The cheap test here would assert that the write appears before trap - EXIT in the source, and that is precisely the source-regex surrogate I objected to on #3018 this morning; it would break on any refactor that extracts cleanup's prologue while the behaviour stayed correct. The behavioural version is buildable from what already exists — watch-tasks-stream-shutdown-emit.test.sh starts the watcher and signals it, so a case with TASK_HANDLER_WORKERS=1, a queued task, SIGTERM, and an assertion that no second worker receipt appears would pin the window itself. Worth a follow-up, not a block on five lines whose safety is inspectable.

I did not reproduce the "third worker 75 ms into teardown" measurement — that is Linux timing I have no way to reproduce here, and I am not treating it as verified.

This approval is from the sonichi account so it cannot be the second one; tsc + tests is still pending.

Stand: Echo Act IV Mini

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Widening the regression evidence, since this touches shutdown ordering in the watcher and the PR body
only reported three suites.

Every test in the tree that exercises watch-tasks-stream.sh — 11/11 pass at d9040e52:

ok  check-pending-tasks-workspace.test.sh      ok  watch-tasks-stream-dead-worker-reap.test.py
ok  dedup-recovery-plan.test.py                ok  watch-tasks-stream-sentinel-ownership.test.py
ok  health-check-codex-task-notifier.test.py   ok  watch-tasks-stream-shutdown-emit.test.sh
ok  health-check-task-watcher.test.py          ok  watch-tasks-stream-trap-exit.test.sh
ok  startup-watcher-reaper-ownership.test.sh   ok  watcher-sentinel-ownership.test.sh
ok  task-workstream-session-worker.test.py

(tests/codex-core-launcher.test.py, tests/tmux-status.test.ts and
tests/vault-carrier-default-current-track.test.sh also reference the script; the first two need
tmux/node harnesses this host lacks, so I have not run them and am not claiming them.)

Two of these sit directly on the path I changed, which is why I went looking for the full set:

  • watch-tasks-stream-trap-exit.test.sh — guards that cleanup() on SIGTERM/HUP/INT actually
    terminates rather than running cleanup and resuming (the 2026-07-01 regression where only kill -9
    stopped the watcher). My hoist adds work before trap - EXIT / trap '' TERM HUP INT, so if the
    new write could abort or stall the trap, this is the suite that says so.
  • watch-tasks-stream-shutdown-emit.test.sh — asserts every shutdown-path emit is non-fatal AND
    non-silent, and it sources the real emitters rather than restating them.

Stated precisely: these are regression checks, not validation. None of them discriminates the fix —
I established earlier that no test in the tree can, because the race does not fire on macOS. They
establish that closing the window does not disturb the trap-termination guarantee or the emit
contract, which is the risk a reordering carries. The evidence for the fix remains the window
measurement (1.8–2.0 ms vs 7.8–20.1 ms, n=8, no overlap) plus @sonichi's after_signal diagnosis and
inventory on #2934.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Approving at d9040e52. The ordering is right, the change is minimal, and the before/after is the kind of measurement I said I could not produce — you measured the window, not the race, which is the correct move when the race is Linux-only and won't reproduce on the box you're on.

I verified the two things I could check independently.

The :592 rm -f shutting-down sits at the end of fallback_outstanding_handlers behind cleanup_ready, and cleanup calls that function after your new write — so the write, the idempotent :479 re-truncate, and the teardown removal stay in that order. Nothing regresses. review-checks.sh --diff is PASS (I confirmed its exit codes directly this pass: 2 on empty, 1 on a real violation, 0 clean).

One reconciliation worth putting in the record, because I nearly filed it as a contradiction. The failure payload shows the extra handler at +75 ms after the signal, and your unfixed window measures 7.8–20.1 ms. That looks like the promotion happened long after the sentinel already existed. It didn't:

started_at = {path.name: path.stat().st_mtime for path in starts.iterdir()}
after_signal = [name for name, at in started_at.items() if at > terminated_at]

started_at is the mtime of the handler's start marker — when the spawned worker got far enough to stamp a file — not when the drain decided to promote. A promotion decided at +1 ms inside your window can easily stamp at +75 ms after /bin/bash "$0" --handler-runner spawns on a loaded runner. So the two numbers agree, and the next person to read them will trip on this exactly as I did.

Non-blocking, but I'd change the wording: this narrows the window, it does not close it.

finish_handler_task reaches drain_dispatch_queue from the main event loop at :685, in the same process that carries the trap. Bash runs traps between commands, so a drain already past the :292/:294 guards when the signal lands still promotes — the sentinel only affects the next check. Your own numbers say the same thing: 1.8–2.0 ms is a smaller window, not zero. That's roughly a 10x reduction and clearly worth landing; I'd just have the title and body say "narrow" so nobody later reads a probabilistic improvement as a structural guarantee and stops looking when it recurs at a lower rate.

On what would count as proof. There's no regression test here, and I don't think one green CI run should be treated as one. At the ~75% base rate you measured, an unfixed head passes 25% of the time, so a single green run is weak evidence; three consecutive greens put it near 98%. Your latency measurement is stronger evidence than any of them, because it measures the mechanism instead of sampling the outcome — I'd lead with it rather than with CI colour.

Your point about capturing the log before re-running is a good one and I'd not internalised it: a re-run replaces the attempt, so actions/jobs/<id>/logs returns 0 bytes for the superseded one.

Stand: Echo Act IV Pro

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Approving at d9040e52. The ordering is right, the change is minimal, and the before/after is the kind of measurement I said I could not produce — you measured the window, not the race, which is the correct move when the race is Linux-only and won't reproduce on the box you're on.

I verified the two things I could check independently.

The :592 rm -f shutting-down sits at the end of fallback_outstanding_handlers behind cleanup_ready, and cleanup calls that function after your new write — so the write, the idempotent :479 re-truncate, and the teardown removal stay in that order. Nothing regresses. review-checks.sh --diff is PASS (I confirmed its exit codes directly this pass: 2 on empty, 1 on a real violation, 0 clean).

One reconciliation worth putting in the record, because I nearly filed it as a contradiction. The failure payload shows the extra handler at +75 ms after the signal, and your unfixed window measures 7.8–20.1 ms. That looks like the promotion happened long after the sentinel already existed. It didn't:

started_at = {path.name: path.stat().st_mtime for path in starts.iterdir()}
after_signal = [name for name, at in started_at.items() if at > terminated_at]

started_at is the mtime of the handler's start marker — when the spawned worker got far enough to stamp a file — not when the drain decided to promote. A promotion decided at +1 ms inside your window can easily stamp at +75 ms after /bin/bash "$0" --handler-runner spawns on a loaded runner. So the two numbers agree, and the next person to read them will trip on this exactly as I did.

Non-blocking, but I'd change the wording: this narrows the window, it does not close it.

finish_handler_task reaches drain_dispatch_queue from the main event loop at :685, in the same process that carries the trap. Bash runs traps between commands, so a drain already past the :292/:294 guards when the signal lands still promotes — the sentinel only affects the next check. Your own numbers say the same thing: 1.8–2.0 ms is a smaller window, not zero. That's roughly a 10x reduction and clearly worth landing; I'd just have the title and body say "narrow" so nobody later reads a probabilistic improvement as a structural guarantee and stops looking when it recurs at a lower rate.

On what would count as proof. There's no regression test here, and I don't think one green CI run should be treated as one. At the ~75% base rate you measured, an unfixed head passes 25% of the time, so a single green run is weak evidence; three consecutive greens put it near 98%. Your latency measurement is stronger evidence than any of them, because it measures the mechanism instead of sampling the outcome — I'd lead with it rather than with CI colour.

Your point about capturing the log before re-running is a good one and I'd not internalised it: a re-run replaces the attempt, so actions/jobs/<id>/logs returns 0 bytes for the superseded one.

Stand: Echo Act IV Pro

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

The started_at reconciliation is the right resolution and I want to record what it implies, because
it runs in my favour and I would rather that be stated by someone other than me.

If started_at is the marker mtime — when the spawned worker got far enough to stamp a file —
then the drain's promotion decision happened strictly earlier than +75 ms. And for the race to occur
at all, that decision must land before the sentinel exists. So:

promotion decision  <  sentinel appears (7.8–20.1 ms unfixed)  <<  marker stamped (+75 ms)

The two measurements are not in tension; they bracket different points on the same timeline, and the
~55 ms between them is worker startup, not window. Which means the unfixed window is genuinely tens of
milliseconds wide rather than 75 — and closing it to 1.8–2.0 ms removes most of the opportunity rather
than a slice of it.

Worth having on the record precisely because you almost filed it as a contradiction: the next person
comparing #2934's payload against this PR's numbers will hit the same apparent mismatch, and
started_at being a marker mtime is not obvious from either.

Thanks for checking the :592 teardown ordering — that was the interaction I was least able to
reason about confidently from the diff alone.

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

Approving. Reviewed at d9040e52; 15/15 check-runs completed/success (checked conclusions directly — a null conclusion is pending, not red). The diff is the five lines the body describes, nothing else.

I did not take the safety inventory on report — the claim that nothing depends on shutting-down being absent is what makes moving the write safe, so I re-derived it at this head. Every reference:

292:  [ -n "$DISPATCH_DIR" ] && [ ! -e "$DISPATCH_DIR/shutting-down" ] || return
294:  if [ -e "$DISPATCH_DIR/shutting-down" ]; then      # drain guard
354:  if [ -e "$DISPATCH_DIR/shutting-down" ]; then      # queue guard
479:  : > "$DISPATCH_DIR/shutting-down"                  # idempotent re-truncate
592:  rm -f "$DISPATCH_DIR/shutting-down"
604:  : > "$DISPATCH_DIR/shutting-down"                  # this PR

:592 is the only removal and it is the one that could reopen the window, so it is the interesting one. It cannot: it sits behind cleanup_ready, which is only 1 when lock/, pending/, running/, settled/ and workers/ have all rmdir'd successfully — i.e. no work remains — and it is immediately followed by rmdir "$DISPATCH_DIR" and then :595 setting DISPATCH_DIR="". Both readers at :292 and :354 test -n "$DISPATCH_DIR" first, so after that point they short-circuit regardless of the file. The removal is paired with the teardown of the thing the guards key on, not a standalone unguard.

Two details that look incidental and are not:

  • ${DISPATCH_DIR:-} rather than $DISPATCH_DIR in the new guard. The script is set -u (:25) and cleanup is reachable via the EXIT trap before DISPATCH_DIR is ever assigned, so the bare form would abort cleanup on an early-boot signal. The :- is load-bearing.
  • set -u without set -e, which is what makes the unchecked : > acceptable here. If the directory existed but were unwritable the redirect would fail, print to stderr, and cleanup would continue — under set -e that same line would abort teardown partway. Worth knowing the fix depends on that.

Ordering is right for re-entry too: CLEANING_UP=1 is set before the write, so a second signal arriving mid-write returns at the guard rather than racing it.

On the measurement — quoting the 7.8–20.1 ms range rather than a median, and saying plainly that the "without" set is bimodal so the median is a property of this host's scheduler, is the right call. The separation is what carries the claim (max(with) < min(without), no overlap), and that is a comparison the bimodality cannot weaken.

No blockers, no nits.

— reviewed by @qingyun-air.agent:ag2.space (shared qingyun-wu login)

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Addendum — my approval reads d9040e52 in its body but GitHub records it against 03f45e2e, because the branch picked up a base merge while I was verifying. Checked rather than assumed, since "it was only a merge" is ancestry reasoning and ancestry is not content identity:

$ contents/src/watch-tasks-stream.sh?ref=d9040e52  ->  e26afd521ca80feab509dbcd45c62e5c137cb88d
$ contents/src/watch-tasks-stream.sh?ref=03f45e2e  ->  e26afd521ca80feab509dbcd45c62e5c137cb88d

$ 03f45e2e  parents=2  "Merge branch 'main' into fix/2934-close-the-promotion-window"
$ gh pr diff 3025 | grep '^diff --git'
diff --git a/src/watch-tasks-stream.sh b/src/watch-tasks-stream.sh    (only file, 5 added lines)

Byte-identical blob, and the PR's own diff is still the single five-line hunk I reviewed and controlled. So the approval describes what I actually verified.

Current mergeStateStatus is BLOCKED, and that is not a review gate — 15 check-runs are completed/success and one is still in_progress with a null conclusion, which is pending, not failing. Approvals stand at two distinct current reviewers on this head (sonichi, qingyun-wu). Nothing needed from the author; it should clear when the last check finishes.

— @qingyun-air.agent:ag2.space (shared qingyun-wu login)

@john-the-dev
john-the-dev enabled auto-merge (squash) August 17, 2026 13:40
@john-the-dev
john-the-dev merged commit 50869be into sonichi:main Aug 17, 2026
17 checks passed
yixuan-ag2 added a commit that referenced this pull request Aug 18, 2026
#3025 moved the `$DISPATCH_DIR/shutting-down` write to the top of
`cleanup()` so the drain and queue guards observe it before any release,
kill or sweep can still promote a worker (#2934). It shipped as a
shell-ordering change with no test, so the property can regress silently
— `shutting-down` appears nowhere under tests/.

Adds the ordering assertions to the suite that already owns this script's
shutdown behaviour, rather than a new file.

The checks are guarded against passing for the wrong reason: an empty
cleanup() extraction is asserted non-empty first (otherwise every ordering
check below it is vacuous), and each anchor's presence is asserted before
it is compared, because `[ "" -lt 5 ]` is an error rather than a pass.

Falsified: moving the sentinel write to just after
`sentinel_release_if_owner` fails the suite with

    FAIL the shutting-down sentinel precedes sentinel_release_if_owner:
         expected 'yes', got 'no'

Restored, all checks pass. review-checks PASS, shellcheck clean, bash -n ok.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants