Repository navigation
fix(watcher): fswatch through a line-atomic relay; run the pointer migration once; pin the answered rule (stacked on #5162) - #5170
Merged
qingyun-wu merged 2 commits intoOct 7, 2026
Conversation
…ter migration once; pin the answered rule Follow-up to #5162 (review asks from marklysze): - fswatch now writes to src/line_relay.py, which drains it at once and forwards each line in its own write() to the event FIFO. Measured on macOS: fswatch 1.18.3 segfaults when a burst (~1,000+ events) backs up behind a full pipe; through the relay it survives, and no line is ever split or merged beside the sweep's writes. src/inbox-events.sh `start_inbox_events` is the launcher. - The watcher runs `task_archive.py retire-archived-pointers` once per workspace, before its first sweep plan, under each inbox's .lock, and records the run in state/migrations/retire-archived-pointers.v1.done. Documented in docs/worker-pool-design.md. - tests/answered-rule-parity.test.py runs the shell and Python "answered" rules over one table. It found that the shell rule read a refusal with no trailing newline as an answer (`read || first=""` cleared the line); fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stub interpreter that proves the sweep's mtime fallback now also stood in for src/line_relay.py, so no event reached the watcher on Linux. It passes the relay through to the real interpreter, as readiness-window-decision-instant does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
|
@cla-assistant check |
qingyun-wu
merged commit Oct 7, 2026
553ef5e
into
fix/watcher-retire-answered-pointers
32 of 40 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #5162. Base is
fix/watcher-retire-answered-pointers. Merge order: #5162 first, then this PR. After #5162 merges I will rebase this ontomain, change the base, and rerun the full checks.What changed, and why
This PR covers marklysze's three non-blocking asks on #5162.
1. FIFO line integrity under an fswatch burst: measured, then fixed
I built a harness,
tests/inbox-events-burst.test.py, to test this. It works as follows:fswatchwith the watcher's exact flags writes into a FIFO.printfper line, 3,000 lines) writes into the same FIFO.BURST_LAUNCH=directruns the launch that fix(watcher): retire answered inbox pointers in the archive step; a restart sweep never blocks new tasks #5162 ships (fswatch writing straight into the FIFO), for comparison.Measured on macOS 26 with fswatch 1.18.3 and 3,000 sweep lines:
Raw output, direct launch:
Raw output, relay launch:
I also isolated the crash without any sweep writer:
On Linux (fswatch 1.14.0 with inotify, in Docker), both launches read all 9,000 lines (Created plus Updated events) with 0 malformed lines and no crash.
Conclusion.
Fix. fswatch never writes to the FIFO itself:
src/inbox-events.shstart_inbox_eventslaunchesfswatch ... > >("$py" src/line_relay.py > "$fifo") &.$!is still fswatch's pid, soFSWATCH_PIDkeeps its meaning for liveness checks,waitand cleanup.src/line_relay.pyreads fswatch's output into memory as soon as it arrives, so fswatch is never blocked. It forwards each line in its ownwrite(), which keeps the line whole beside the sweep's lines as long as it is ≤ PIPE_BUF (512 bytes on macOS).Two test shims needed adjusting, because the relay runs on the injected interpreter:
readiness-window-decision-instantwrapsSUTANDO_PYin a shim that buffers all output.priority-sweepuses a broken-helper stub that exits 0.Both now pass
line_relay.pystraight through to the real interpreter. The second one failed in this PR's first CI run on Linux.2.
retire-archived-pointers: documented and run once automaticallydocs/worker-pool-design.mdnow has a section "Retiring a finished sentinel". It says the archive step is the one owner of "done", what the migration moves, why it is idempotent, and when it runs.retire_archived_pointers_onceinsrc/watch-tasks-stream.shworks as follows:state/migrations/retire-archived-pointers.v1.doneis absent..lock, the same lock the archive step uses.tests/watch-tasks-stream-pointer-migration.test.py: a real watcher starts on a temp worker inbox with 3 archived pointers and 1 pending pointer.At the parent (#5162 head):
At HEAD:
watch-tasks-stream-sweep-does-not-block-new-tasksnow pre-writes the marker. Its 3,000 pointers therefore stand for leftovers on a migrated workspace; otherwise the migration would retire them all before the plan ran.3. Parity of the "answered" rule, shell vs Python
tests/answered-rule-parity.test.pyruns one table of 19 cases through both implementations:handler_result_is_answer, lifted verbatim fromsrc/watch-tasks-stream.shtogether with its ownTERMINAL_REFUSAL_MARK;task_dispatch._is_answer.The cases cover: no result, an answer with and without a trailing newline, a refusal with and without a trailing newline, CRLF, the refusal phrase mid-line, a refusal on line 2, a bare CR, an indented refusal, whitespace-only and empty placeholders, archive-only results, an empty live file with an archived answer or refusal, non-UTF-8 bytes, and a directory at the live name.
The test found a real divergence, so I fixed the shell rule. The old line was
IFS= read -r first < "$ready" || first="". On a last line with no newline,readreturns 1 even though it filled$first, and the old line then cleared it. So a refusal written without a trailing newline was treated as an answer, and the task was never re-run. The watcher's own refusal writer always appends a newline, so only refusals from other writers or truncated ones were affected. The new line is|| true.At the parent:
At HEAD: all 19 cases agree.
PASS.Review first
src/line_relay.pyandsrc/inbox-events.sh, plus the one-line change inlaunch_fswatch.retire_archived_pointers_onceinsrc/watch-tasks-stream.sh.handler_result_is_answerline change.Docs
docs/worker-pool-design.md: a new "Retiring a finished sentinel" section, and the relay is described in the watcher-liveness paragraph.docs/src-map.mdis regenerated for the two newsrc/files.Tests run
All suites ran with every
SUTANDO_*variable unset and a temp workspace. The set is:tests/watch-tasks-stream*,watcher-*,task-dispatch-*,task-archive,delivery-pointer-retirement,tests/skills/worker-pool/*,worker-pool-design-*,pool-*,check-pending-tasks-*,core-never-names-the-worker-pool-skill,sync-from-src-repo-relative,watch-tasks-dispatch-activity, plus the three new tests.watch-tasks-stream-shutdown-emit.test.shfails identically on both, as already noted on fix(watcher): retire answered inbox pointers in the archive step; a restart sweep never blocks new tasks #5162.ruff,shellcheck -S error(koalaman/shellcheck:stable),scripts/review-checks.shandgen-src-map.py --checkare all clean.CI at HEAD
Shell lanes, serial tail, shellcheck, ruff and the license (CLA) check are green. Only three python tests fail:
tmux-session-targets-are-exactagy-task-notifieragy-notifier-dependency-gateThese three fail on main and on #5162 too.
external-core-helpersfailed once and passed on rerun. It compares a shared/tmp/core-heartbeat.logthat parallel tests write.tsc + tests (clean install)is the job that aggregates those python shards.No host paths appear in added lines.
🤖 Generated with Claude Code