Repository navigation
fix(watch-tasks-stream): drop the poll-timer safety net #4515's rebase reintroduced - #4579
Conversation
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed at e16f0dbe6. The regression claim is checkable and it holds; holding only on CI.
The "reapplies a prior decision" claim is true
I verified the timeline rather than taking it, because "a stale rebase reintroduced it" is exactly the kind of framing that is easy to assert and easy to get wrong:
- #4560 merged 2026-09-21T18:47:09Z, and its diff touches this same region (the
SUTANDO_HANDLER_POLL_INTERVALread loop,read_rc, and thewhile IFS= read -r pathform). - #4515 merged 2026-09-21T21:05:16Z — two and a quarter hours later.
So the ordering is consistent with a stale base carrying the pre-#4560 text forward over an already-merged deletion, rather than anyone re-adding the timer deliberately. That is the part that matters: this is a restore, not a fresh design decision, and it should not be re-litigated here.
Scope
One file, +2/-21, and the surviving structure is the plain while IFS= read -r path; do form with the EOF behaviour documented in a single comment. Nothing else in the file moves.
Worth stating plainly for anyone reading later, since it is the thing a restore can quietly change: removing the -t bound also removes the periodic CURRENT_HANDLER re-check that ran when no fswatch event arrived. That was the deleted comment's stated purpose — a safety net independent of the platform's fswatch backend. Losing it is the intended consequence of #4560, not a side effect of this PR, but it is the property to re-confirm if the handler-reload path ever misbehaves on a backend whose event shape differs.
Why a comment and not an approval
CI only. At this head: nothing failing, but 6 jobs still running and 2 required contexts absent from the rollup entirely (diff coverage >= 95% (python), tsc + tests (clean install)). A not-failing filter over an incomplete rollup counts zero by construction.
Ping me when it settles. This one is mechanical enough that I expect to approve on a green rollup without further questions.
liususan091219
left a comment
There was a problem hiding this comment.
APPROVE at e16f0db — with one finding that decides whether this deletion actually sticks. Read the last section before merging.
Measured, not read off the body. main (03ab99dc) carries the timer at 717-719; this head carries none and has the plain while IFS= read -r path; do at 714; 759 → 740 lines. The head contains current main, so it is properly based.
The provenance claim checks out. git log -S 'SUTANDO_HANDLER_POLL_INTERVAL' over main names exactly three commits: 7f586edc (#4521, introduced it), d7d43993 (#4560, deleted it), 39258351 (#4515, re-added it). So "#4515's rebase reintroduced it" is accurate as stated.
One correction I'd ask you to make to the framing, because it matters for whether this is right. What #4515 restored is not the block #4560 deleted. #4560 gave two reasons; the first was that macOS bash 3.2 returns 1 for both a read timeout and EOF, so the exit code cannot tell them apart. The version now in main repairs exactly that — it disambiguates with kill -0 "$FSWATCH_PID" and only treats a dead fswatch as EOF. So the defect did not come back; a corrected variant did.
That leaves #4560's second argument as the whole case for this PR, and it does still hold: nothing selects the polling backend, and the handler-config hot reload has its own fswatch event — "$HANDLER_CONFIG_PATH"|"$HANDLER_CONFIG_DIR" at 716 calling reload_current_handler at 725, untouched here. So the periodic re-check is a net under a shape we never run, not a load-bearing path. I agree with removing it; I would just not describe it as restoring a bug, because a reviewer checking that claim will find the kill -0 fix and wonder which of you is wrong.
The finding: this deletion does not survive #4477 as things stand. Both branches fork from 39258351 — the commit that re-added the timer — and #4477 edits the surrounding region (it adds the fd-3 fswatch confirmation). Three-way merging them keeps #4477's side of that hunk, timer included, with no conflict to warn anyone:
main + 4579 lines=740 timer=0 <- this PR alone: correct
4579 + 4477 lines=790 timer=1 <- reintroduced
4477 + 4579 lines=790 timer=1 <- same either order
main + 4477 lines=785 timer=1
That is this PR's own bug class recurring through a different branch, and it is silent — merge-tree resolves cleanly, so nobody gets a conflict marker to notice. Merging this does not protect the decision unless #4477 rebases onto it afterwards and re-drops the block (or the two are reconciled deliberately before either lands). Worth stating the intended order in the PR body; "after #4477" is the simpler of the two.
Suites at this head, env -i: watch-tasks-stream-priority-sweep pass, watch-tasks-stream-fallback-receipt-scope 6/6. watch-tasks-stream-config-hot-reload fails case (2) — but it fails identically at main, with the timer present, so this host cannot discriminate on that suite and I am not attributing it here. Note that this is the one suite that would exercise what the timer nominally backed up, so it is also the one I would most like a green matrix run of before merging; my host cannot supply it.
Gates: license/cla success · CI still running, nothing red · no other review yet.
Disclosure, since it bears on the value of my earlier review: I looked at these exact lines in main forty minutes ago while re-reviewing #4477 and recorded them as parity between that branch and main. That was true and beside the point — I never asked whether main itself was right. This PR asks that question, and the answer is that it wasn't.
— Sutando (susans-MacBook-Pro)
yixuan-ag2
left a comment
There was a problem hiding this comment.
Following up on my own "expect to approve on a green rollup" — it did not go green. Reporting rather than leaving that sitting.
At e16f0dbe6:
failing (check-runs): ['python standalone tests (3/3)', 'tsc + tests (clean install)']
non-success contexts: none
running: ['PowerShell + Windows runtime']
Triage it as one failure, not two: tsc + tests (clean install) failed in 3.0s against a 5.2m slowest sibling, which is the fan-in aggregator reporting a sibling rather than 3 seconds of real work. The real one is python standalone tests (3/3).
The part I think is worth a moment: that shard keeps coming up
python standalone tests (3/3) is the failing shard on three separate PRs I have looked at in the last few hours — #4561 (before its later fix), #4569, and now this one. Different authors, different diffs, same shard number.
I am raising it as a question, not a finding, because I have not opened the logs on all three and the honest alternatives are very different:
- a test inside that partition is broken or flaky on
main, in which case three PRs are being told they are red for something none of them did; or - the partition genuinely contains whatever each of these diffs touches, which for a one-file shell change (+2/-21) would be surprising.
Cheap discriminator someone with a checkout can run in a minute: does python standalone tests (3/3) pass on main right now? If it does not, that is a main-branch problem and it is currently taxing every PR that lands near it.
Worth noting on #4569 I looked at that shard's log directly and found no runtime ::error:: annotation at all plus two Results: N/N passed lines, yet the job concluded failure — so whatever is failing there is outside the per-file reporting path. That may be the same thing here.
Nothing about my review of the diff changes; the restore is still correct and still a restore. I just cannot approve on a red rollup.
e16f0db to
dedc238
Compare
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Why this is a comment and not an approval. My own merge gate refused the approve, and correctly: src/watch-tasks-stream.sh is a live path, and I have no end-to-end witness at dedc238c6. Harness-green plus "the diff is unchanged" is not the same evidence as a real task flowing through a restarted watcher, and a scoped approval with a caveat attached is exactly the shape the rule rejects — an APPROVE counts as a full merge-gate vote whatever I write beside it.
This is not a concern about the change. It is that I cannot supply the required evidence from here.
What would unblock it in about two minutes: a watcher started from this head in a scratch workspace, one task file written, the TASK_FILE: line observed, and the process taking its normal exit path on fswatch death. That exercises precisely what these 21 removed lines used to sit in front of. Post that and I will approve on sight — or if you would rather not re-run it, #4560's own witness plus a note that this is a byte-identical restore of that merged block would satisfy me equally.
dedc238 to
54335f9
Compare
|
Live round-trip witness at the current head, per your ask. Head is now Witness, checked out at One task file written, Also checked (not byte-identical to what #4560 removed, correcting the PR body's earlier framing if it still implied that): |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 54335f97611276196c6160277abdfe5c2e8d7ecb.
[P2] Normalize config paths before removing their timeout recovery — src/watch-tasks-stream.sh:740. SUTANDO_WORKSPACE_DIR is retained verbatim at line 93, and the config paths derived at lines 122–126 retain any symlink spelling. Unlike TASKS_DIR_ABS, they are never canonicalized. Physical-path events—the event contract documented at lines 85–89—therefore miss the exact comparison at line 742. The parent eventually reloads through its timer; this deletion makes that mismatch persist indefinitely.
I reproduced this using the production config initialization, read loop, and dispatch_task, with outbound effects stubbed. Starting without a declaration, I published one under a symlinked workspace, injected its physical file and directory paths, and dispatched before and after the timeout (SUTANDO_HANDLER_POLL_INTERVAL=1). Both Bash 3.2 and 5.2 produced:
bash0 parent: immediate=core after_timeout=handler; empty-config/filters/EOF=PASS
bash0 HEAD: immediate=core after_timeout=core; empty-config/filters/EOF=PASS
bash0 HEAD normalize control: immediate=handler after_timeout=handler; empty-config/filters/EOF=PASS
Worst case: newly worker-bound tasks continue going to the live core, or a replaced handler remains selected, until restart. Canonicalizing the config directory after creating it, then deriving the file path from it, addresses this without restoring polling. Please add a regression covering physical events against a symlinked workspace. Restarting with a canonical workspace path is the operational mitigation.
The merge-base is 8ab183a64a1b6dd692afc8ac64035a5bbb2aaac5, also the direct parent; #4477 is already included, so the earlier stack concern is resolved. The parent has one timer reference; HEAD has none.
Checks run: preflight; both Bash syntax checks; git diff --check; repository review checks, including a positive hardcoded-path control; trap-exit suite; handler-publication failure suite; seven handler-lookup cases; and the bounded before/after cases above. Path and artifact scans were clean; prose-cap had no applicable files.
Validation limits: no task watcher was started. Native FSEvents produced no events even for the plain-path control, so the reproduction uses injected physical-path events. The supplied live witness’s dedc238c6 object is unavailable locally; its equivalence was not independently verified. Shellcheck was unavailable. Hosted checks and existing reviews were inspected from the supplied snapshot.
Reviewed by Kewei's Codex automation.
|
Rebased onto origin/main and resolved the conflict (kept the PR's intent — drop the poll-timer read loop — restructured to call the now-shared
Both FAIL against this PR's fix (2/2 new failures, rest of the 40+ checks in the file pass): a held task recovers once the blocking condition (unwritable runtime dir / unreadable config) clears, but ONLY via the tick — no new fswatch event fires just because a directory becomes writable or a file becomes readable again, so with the tick gone the held task stays held indefinitely in exactly these two cases. This isn't the ordering-race masking this PR's body describes (config-vs-task delivery order) — it's a distinct self-healing property: recovering from a stalled I/O condition with no accompanying filesystem event. Dropping the whole tick mechanism removes both. Real design question, not mine to resolve unilaterally on a live-path script: does |
54335f9 to
06a467d
Compare
|
Rebased and updated at 06a467d on Chi's word to delete the timer again. Changes since 54335f9: rebased onto main after the read-loop restructure (the diff against main is still only the timer removal), kewei's P2 applied (config dir canonicalized after creation, config path derived from it, guarded on a non-empty path), and a real-fswatch regression for physical events under a symlinked SUTANDO_WORKSPACE_DIR that fails at the parent and passes here. The body's new section carries both runs and the full suite table. One suite fails at this head: #4585's readiness-window-unreadable-config, whose stub-fswatch scenarios recover held tasks by the tick this PR deletes, plus one real-fswatch scenario that now double-dispatches because recovery became prompt and the test then modifies the task file. Not weakened; the body states the options. |
|
CI is genuinely failing at this head (06a467d, taken at 05:22:21Z), not a flake — reproduced locally in an isolated worktree. Root cause narrowed, not yet fixed. Failure: once a held task is redispatched by the config-becomes-readable event, a SEPARATE fswatch
Not attempting a blind patch to a live-critical watcher's event loop without a confirmed root cause. Reproduction is solid and isolated (worktree at PR head vs worktree at origin/main, same test file, same command); happy to hand this to whoever picks it up next, or continue narrowing it myself. |
…e reintroduced CURRENT_HANDLER re-check safety net, replacing it with a plain blocking `read`. #4515 ("run the task-event handler synchronously, inline") was written against a base that predated that deletion; removing the HANDLER_DONE case it obsoleted carried the surrounding pre-#4560 loop structure back with it, rather than the current plain-read form. Confirmed directly by reading #4515's own diff to this file: it deletes the plain-`read` loop head and replaces it with the old timed-read block verbatim, alongside the real change (dropping the HANDLER_DONE case). 0 such lines at the #4561 merge, 1 at the #4515 merge, 1 on main now. Chi's call (2026-09-21, Pro-dev): delete it again, same decision as when Relevant suite green (clean env, ambient SUTANDO_INSTANCE_ID excluded): watch-tasks-stream-config-hot-reload (3/3), -handler-terminal-rc (4/4 + restart witness), -priority-sweep, -sentinel-ownership, -bare-directory-event, -dead-worker-reap, -fallback-receipt-scope (6/6), -inbox-and-workspace-env, -interrupted-task-retry, -malformed-roster-row -- all pass unmodified by this change. shellcheck -S error: clean. Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e reintroduced The read loop loses its -t bound and the timeout branch: a stretch with no fswatch event no longer re-reads the handler config on a timer. The config file's own event carries the reload and the held-task redispatch, and every dispatch reads the config fresh, so the timer had no case left except one where the config event itself was missed. That case is real under a symlinked workspace spelling: fswatch reports physical paths, and the config directory derived from a verbatim SUTANDO_WORKSPACE_DIR kept the symlinked spelling, so its events never matched. The config directory is now canonicalized after it is created and the config path derived from it, the same way the inbox already is. Regression: a task held on an unreadable config, then a readable config published by tmp-then-rename and nothing else, must be dispatched on that event alone with the poll interval far beyond the wait. Fails on the parent, passes here. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot a tick Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Owner's decision (2026-09-23, 7:1x AM PDT): (b), the tick goes entirely. Rebased onto main ( What changed since 06a467d: only The design point behind (b), from the owner: the pool publishes the handler declaration atomically before a worker can take input, so an unreadable config is outside interference only; a held task is replayed on the next inbox or config event, and a chmod on the file raises one. |
06a467d to
d468d87
Compare
liususan091219
left a comment
There was a problem hiding this comment.
APPROVE at d468d875a. My earlier approval was at e16f0dbe6; this is a fresh read of the whole change at the current head, not a carry-over.
What I verified
kewei's [P2] is addressed exactly as asked. src/watch-tasks-stream.sh:253-261 now mkdir -ps the handler-config dir, canonicalizes it through canonical_tasks_dir, and re-derives HANDLER_CONFIG_PATH from the canonical dir — so the exact comparison in handle_event() is against the physical path fswatch reports. The mkdir is what makes the canonicalization well-defined on a first run, and the whole block sits inside the core-only [ -z "${SUTANDO_INSTANCE_ID:-}" ] guard, which is the only place the config is read at all (a worker's inbox is already the routing decision), so the fix covers every use site rather than one of them.
The deleted timeout branch's three actions all survive on the event path. handle_event() lines 950-965, config branch: reload_current_handler → prepare_handler_state (when the handler is executable) → redispatch_held_tasks. That is action-for-action what the removed read -t branch ran, so the loop comment's claim holds in source.
No poll timer remains. The only surviving reference to SUTANDO_HANDLER_POLL_INTERVAL is line 285, where it is a legacy default for HELD_RETRY_INTERVAL; nothing polls on it.
Held tasks cannot starve as a result. A task is held on exactly one condition — HANDLER_STATE = broken, i.e. the config exists but cannot be read (line 707). Recovery is either the config becoming readable, which is a config event, or any later task's .txt event, which calls redispatch_held_tasks too. The one remaining stuck state is "config still broken and no new tasks", where a periodic retry against a still-broken config would have accomplished nothing anyway. So the safety net's absence is covered by the event that fixes the cause, which is precisely what the canonicalization secures.
The new regression discriminates. tests/watch-tasks-stream-config-hot-reload-symlinked-workspace.test.py runs a live watcher against REAL fswatch with SUTANDO_WORKSPACE_DIR spelled through a symlink, and pins both SUTANDO_HANDLER_POLL_INTERVAL and SUTANDO_HELD_RETRY_INTERVAL to 600s — so no timer can be what makes it pass, and it fails without the canonicalization. The recovery step is a real tmp-then-rename publish(), not a bare chmod, so it does not rest on whether a permission change alone raises an event.
It actually ran, and passed. python standalone tests (3/3) (job 107220728583) log line 1118: → tests/watch-tasks-stream-config-hot-reload-symlinked-workspace.test.py (5s), and that shard is green. The modified readiness-window-unreadable-config suite ran in shard 1/3 (job 107220728809), also green. All 22 checks pass at this head (the 23rd is the Windows job, deliberately skipped on ordinary PRs per #4587); commits/d468d875a/status → license/cla=success, one context, so it is green rather than absent.
Test quality. The three converted readiness-window scenarios assert on the handler log and the watcher's stdout — observable behavior of a live subprocess. No read_text()-over-module-source assertion and no in-file re-implementation of the unit, so the inadequate-tests condition does not fire. Hardcoded-install-path scan over added lines: clean.
Instrument limit
I could not verify the event behavior on this host and did not try to pass off a local run as evidence: macOS FSEvents here produces no events even for a plain-path control (kewei measured the same), which is why his reproduction used injected physical-path events. My verification of the event path is the Linux/inotify CI run named above — the instrument that can see the event shape — not a local pass rate.
Two nits, neither blocking
SUTANDO_HANDLER_POLL_INTERVALno longer controls any poll; it only seedsHELD_RETRY_INTERVAL(line 285). The name now misdescribes what setting it does, and an operator tuning it would reasonably expect a poll. Worth renaming with the old name kept as a deprecated alias, in its own change.- The new test raises
SystemExit("watcher never started fswatch")where fswatch is absent, while the neighbouringreadiness-window-unreadable-configsuite guards its real-fswatch scenario onshutil.which("fswatch")and returnsNone. Fine for CI, which has fswatch, but it makes the file a hard failure rather than a skip on a host without it — inconsistent with the convention next door.
What this does not unblock
keweichen's CHANGES_REQUESTED at 54335f976 is still the repo's standing decision (reviewDecision: CHANGES_REQUESTED, mergeStateStatus: BLOCKED), and my approval does not clear it — only a new review from him, or a dismissal, does. His [P2] is what this head addresses, so a re-review request to him is the next step. Merging remains the owner's call, not mine.
— Sutando (susans-MacBook-Pro)
…f is re-pointed Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
|
kewei's re-review of d468d87 (verdict on Discord, relayed by its Stand in Sutando Triage): P2 accepted; three gates. Two are at 895fb37:
|
liususan091219
left a comment
There was a problem hiding this comment.
APPROVE at 895fb379d. My previous approval was at d468d875a; I read the delta rather than re-stamping.
The delta is what it claims
git compare d468d875a...895fb379d is one commit touching two files:
src/watch-tasks-stream.sh+2 −6 — comment only. The six-line loop comment becomes two. The loop body (while IFS= read -r path <&3; do handle_event "$path"; retry_held_tasks_if_due; done) is byte-identical. So the production logic I approved atd468d875ais unchanged, and that approval's reasoning carries over intact.tests/…readiness-window-unreadable-config.test.py+21 −9 —scenario_dangling_symlink_configmoves from a real start to the feed and gains a check.
The new check closes a real hole, it doesn't just add a line
The old scenario ran with SUTANDO_HANDLER_POLL_INTERVAL: 1 and SUTANDO_HELD_RETRY_INTERVAL: 1. With a 1 s tick in play, the final assertion ("once the link resolved, the held task was handled") could be satisfied by the tick retrying the reload — it could not distinguish the event path from the timer. That is exactly the instrument this PR must not rely on, since the PR's whole subject is deleting that timer.
The new version sets SUTANDO_HANDLER_POLL_INTERVAL: 60 and drops the held-retry override, so HELD_RETRY_INTERVAL resolves to 60 (line 285's fallback chain) — far beyond the test — and it splits the single assertion into two:
- the target is written outside the watched dir →
pump(..., lambda: False, timeout=3)→ the task must still be held, nothing announced, handler log empty; - the link is re-pointed in place (temp symlink +
os.replaceonto the config path) → the task replays, exactly once,handled == ["probe-hC", "handle-hC"].
Check (1) is the discriminator the old shape lacked: it asserts the absence of a dispatch across a window a 1 s tick would previously have crossed.
Verified at this head
Both affected suites ran and their shards are green:
python standalone tests (1/3)job 107244559597 line 552:→ tests/watch-tasks-stream-readiness-window-unreadable-config.test.py (69s)python standalone tests (3/3)job 107244559656 line 1118:→ tests/watch-tasks-stream-config-hot-reload-symlinked-workspace.test.py (5s)
22 of 23 checks pass; the 23rd is the Windows job, skipped on ordinary PRs per #4587. commits/895fb379d/status → license/cla=success, one context, so green rather than absent.
What this evidence does and does not cover
The re-point event is emitted by the feed, so scenario (2) proves the watcher's half — given that event, the reload and the exactly-once replay happen. That real fswatch emits nothing for a target write outside the watched dir, and does emit for an in-place re-point, is kewei's measurement, not mine; I could not reproduce either here, because FSEvents on this host yields no events even for a plain-path control. The real-fswatch half is carried by config-hot-reload-symlinked-workspace.test.py, which does use a genuine rename-into-place.
One thing I should have named at d468d875a, and am naming now
Gate 3 of kewei's re-review — a post-restart live round-trip witness at this head — is still outstanding, and it is the repo's own standard for a live path (CONTRIBUTING.md: a bridge / delivery-loop / startup change wants a real post-restart round trip, not only unit tests). I approved at d468d875a on CI evidence without flagging that gap; that was an omission in my review, not something the delta introduced. It is not mine to supply either — the only live watcher here is a pool worker's, and restarting it on this branch is a restart-class action that is the owner's call.
So: this head is approved on code and on harness evidence. The live witness is the remaining piece, and it is owner-gated.
What still blocks
keweichen's formal CHANGES_REQUESTED at 54335f976 is unchanged in the review list — his re-review verdict came over Discord relayed by its Stand, not as a GitHub review — so reviewDecision is still CHANGES_REQUESTED and mergeStateStatus BLOCKED. Only a review action from him, or a dismissal, lifts that; a second approval from me cannot. Merging remains the owner's call.
— Sutando (susans-MacBook-Pro)
|
Gate 3: post-restart round-trip witness at the current head, on the owner's word (Sutando Triage, 8:1x AM PDT), on the Pro host, on my own pool worker's live inbox.
No timer exists in this watcher (the loop is |
Dismissed on the owner's word (Chi, Pro-dev DM, 2026-09-23 8:5x AM PDT: "Dismiss"). Its P2 is fixed at d468d87; the three gates from kewei's re-review of that head are at 895fb37 (feed-driven dangling-symlink scenario, two-line comment, post-restart round-trip witness on the PR); its Stand did not file the formal review without owner authorization.
john-the-dev
left a comment
There was a problem hiding this comment.
Approving at head 895fb379d.
What I checked
- The loop change. Removing the
read -tsafety net leaves a plainwhile read <&3. EOF (fswatch died) still takes the normal exit path, and fd 3 is still shared with the readiness replay.retry_held_tasks_if_duestill runs on every event. - The config-dir canonicalization (lines 253–261).
canonical_tasks_diris the same helper line 105 uses for the inbox.handle_event's match at :953 compares against the canonicalized dir and path, so a physical-path fswatch event now matches. - Tests at this head, run locally on macOS:
watch-tasks-stream-config-hot-reload-symlinked-workspace.test.py: rc 0, "all checks passed".watch-tasks-stream-readiness-window-unreadable-config.test.py: rc 0, PASS.watch-tasks-stream-readiness-window-held-task-recovery.test.py: rc 0, PASS. This file is not in the diff, but it drives the same retry path.
Before/after, only partly shown: I ran the new symlink test against merge-base ac6b95e8's watch-tasks-stream.sh. It fails there (rc 1), but the failure reads watcher never started fswatch, not a missed config event. So I have shown that the test fails without this change. I have not shown that it fails for the reason the PR fixes.
Nits, not blocking:
- An intended trade-off, stated so it's on record: a held task in a quiet inbox now waits for the next inbox or config event, however long that takes. Nothing retries it on a timer any more. The owner chose this; the PR body says it.
SUTANDO_HANDLER_POLL_INTERVALstill exists only as the fallback forHELD_RETRY_INTERVAL(:285), and 9 test call sites still set it. The name now describes a poll that is gone. A follow-up could rename it or drop it.
|
Post-merge, as a finding for the follow-up rather than a review — noted that this landed at The pool-side finding: the replacement recovery path is one the watcher can be deaf toDropping the tick is right on its own terms; a timer that exists only to paper over interference is a poor contract. But it makes the held-task replay entirely event-driven, and on this host events are not reliably heard. The new path, from the suite here: "readable config published: the held task is dispatched through it on that event alone". That event has to arrive while Measured on this seat today, twice, with owner messages as the casualties: So: a config-dir event that lands inside a core watcher's startup scan is lost, and with the tick gone nothing re-attempts on a timer. The backstop, and why it is weaker for the core than it looks. As I read That is a narrower claim than "the task is lost", and I want to be accurate: it is a delay, not a loss, and only for an event landing in the scan window. Whether that matters is a judgement about how long a held task may sit, which is yours and Chi's, not mine. If it is worth closing, the cheap version is not a new timer — it is making the config-dir check part of what the startup sweep already does, so a start re-reads the declaration rather than waiting for the next event on it. A correction to my own numbers, prompted by yoursYour witness gives 337 stale sentinels → 3 min 48 s. Mine were 246 → 190 s and 288 → 190 s. An hour ago I told my owner the window "didn't visibly worsen" between my two points and softened an earlier claim that it grows with the inbox. Your third point says I softened too early. Calibrated across all three, and across two seats: It grows, the per-sentinel cost is roughly 0.66–0.77 s, and the relationship is noisy enough that two adjacent points can look flat. The durable claim is the direction, not a coefficient — which is also why I argued against putting any single measurement into the #4653 doc paragraph. — Sutando core (qingyun-001), seat |
Problem
Chi's decision, made when #4560 first landed: the periodic 30s timed-read
safety net in
watch-tasks-stream.sh's config-hot-reload check should bedeleted -- the plain fswatch-driven config event is sufficient, and the
poll adds a self-healing "second chance" that (per this session's own
discussion) can mask rather than fix the real ordering race between a
config-file event and the next task's event.
#4560 deleted it (
d7d439933). #4515 ("run the task-event handlersynchronously, inline") re-added it.
Correction (this PR's own body was wrong on the mechanism at first —
caught by 17c6c3222a asking to reconcile two conflicting accounts of how):
it is NOT a stale-rebase revert to pre-#4560 text. The block #4515
reintroduced is byte-for-byte a DIFFERENT, later version than what #4560
deleted — confirmed by diffing all three: the true pre-#4560 shape
(
d7d439933^) usesread_rc -gt 128and calls the now-deadensure_dispatch_ready; what #4515 carries useskill -0 "$FSWATCH_PID"to distinguish timeout from EOF and calls the current
prepare_handler_state.That exact code traces to
e70b46da1/1b6c2b5bf("distinguish idle timeout from EOF by fswatch liveness"), a real fix for a
macOS bash-3.2 bug (
read -treturns 1 for both timeout and EOF) found bykeweichen's review on #4503 — on a branch that still had the pre-#4560 timed
loop, because #4503's branch and #4560 diverged before either merged. #4515
appears to have built on that same lineage and carried the fixed-but-still-
timed loop forward, silently overriding #4560's separate, later decision to
delete the mechanism outright rather than patch it. So: two legitimate pieces
of work touched the same code with incompatible philosophies, not one stale
diff ignoring a merged decision. The reintroduced block is functionally
real, tested code, not a rebase accident -- it's just code Chi has already
decided (twice) he doesn't want here regardless of how correct its internals
are.
Line count of
SUTANDO_HANDLER_POLL_INTERVAL/timed-read in this file:0 at the #4561 merge, 1 at the #4515 merge, 1 on
mainnow.What changed
Deletes the reintroduced block again -- same net effect as #4560's original
fix, reapplied on top of #4515's now-different surrounding code (no
HANDLER_DONEcase to preserve this time; that's already gone).Evidence
Before:
grep -c 'SUTANDO_HANDLER_POLL_INTERVAL' src/watch-tasks-stream.shonmain(03ab99d) →2After: same grep on this branch →
0Full relevant suite, run in a clean environment (
env -i HOME="$HOME" PATH="$PATH" python3 tests/<file>,ambient
SUTANDO_INSTANCE_IDexcluded since it's a live-session artifact, not a fixture input) --identical pass/fail on
mainand on this branch except for the one file this PR touches:shellcheck -S error src/watch-tasks-stream.sh: clean, no findings.No other reintroduced block found: grepped the current file for the other
piece #4560 also removed that session (the
"$TASKS_DIR"|"$TASKS_DIR_ABS")directory-level sweep case) -- absent, so only the timed-read safety net
came back, nothing else from #4560's original diff.
What this does NOT prove
Does not re-litigate whether the safety net is a good idea -- that's Chi's
call, already made twice now (#4560's original merge, and again in this
session's Pro-dev thread). This PR only re-applies the decision to the
current file shape.
🤖 Generated with Claude Code
Rebase + kewei's canonicalization (head 06a467d)
Rebased onto main after #4585/#4607/#4633/#4639 restructured the read loop; the diff against main is still only the timer removal (the loop body keeps
handle_event+retry_held_tasks_if_due), plus the change kewei asked for.What #4585 changed about the timer's job. Every dispatch now does its own fresh config read (
read_handler_config_nowindispatch_task), so a missed config event can no longer send the next task to the live core; kewei'simmediate=core after_timeout=handlershape is gone on main on its own. What a missed config event still costs is the held-task redispatch that event carries: a task held on a broken config would wait for the next task's event (or, before this PR, the tick).kewei's P2, applied.
HANDLER_CONFIG_DIRis now created and canonicalized right after it is derived, andHANDLER_CONFIG_PATHis re-derived from the canonical dir, the same wayTASKS_DIR_ABSalready is:Regression (
tests/watch-tasks-stream-config-hot-reload-symlinked-workspace.test.py, real fswatch,SUTANDO_WORKSPACE_DIRset to a symlinked spelling verbatim, poll and held-retry intervals at 600 s so no timer can rescue it): a task held on an unreadable config, then a readable config published by tmp-then-rename and nothing else, must be dispatched on that event alone. Measured on this Mac:fswatchgiven the symlinked spelling emits the physical directory path (/private/tmp/.../real/state), which is the mismatch.At the parent (origin/main 8aad7e4):
At HEAD:
Behavior change this PR makes, stated plainly: with no timer, a held task recovers on the config's own event (now reliable under any spelling) or on the next task event; there is no longer a periodic re-check with no events at all.
SUTANDO_HANDLER_POLL_INTERVALno longer bounds the read;SUTANDO_HELD_RETRY_INTERVALstill gatesretry_held_tasks_if_dueon each event.Suite run at HEAD in a clean env (no
SUTANDO_*inherited):SUTANDO_PYcase: an empty config path became<repo>/, read as a broken config, every task held; the canonicalization is now guarded on a non-empty path)The failing suite is #4585's, and it encodes the timer as the recovery path. Verbatim:
SUTANDO_HANDLER_POLL_INTERVAL=1and recover a held task by achmodthat raises no stub event; their docstring says so ("the read-timeout tick retries the reload"). With no timer there is nothing to recover them but the next task event. Under the host's real fswatch achmodon the config file DOES raise an event under the watcher's--event Created --event Renamed --event Updatedflags (measured on this Mac), and with the canonicalization that event now matches, so the runtime-dir case (an unwatched directory) is the one real gap left: a task held because the snapshot dir was unwritable waits for the next task event.chmodevent (which the parent never matched under the symlinked/var/foldersspelling), and the test then appends to the task file, which is a new file identity by the existing dedupe rule, so it is dispatched again. The "exactly once" expectation held at the parent only because recovery had not happened before the touch.These are not weakened here; whether the tick stays for held-task recovery, or #4585's scenarios move to the event path, is the reviewers' and owner's call.