Repository navigation
fix(worker-pool): restore the done-flag writer, reached through the adapter edge - #4244
Conversation
…dapter edge `pool_delivery.residue()` branches on the done flag and `sweep()` retires only on it, but nothing in the repo writes it: `mark_done` shipped in #4175 — added because a reviewer blocked that PR on this exact gap — and was lost when #4175 closed as superseded. The readers survived consolidation; the writer did not. So the `finished` state was unreachable for every seat except one agent that writes flags by hand, and `state/workers/<wid>/done/` does not exist for the others. Restored on the skill side (`pending_flag`, `is_done_flag`, `_publish_record`, `mark_done`, and a `mark-done` CLI) with the two-stage contract #4175 defined: `.pending` before the result so a result the drain can see always has attribution beside it, `.flag` after, and no demotion so a late pending cannot reopen retired work. A literal restore is no longer legal: #4175 called `src/pool_delivery.py` when the module was core, and #4206 moved it into the skill, where `tests/core-never-names-the-worker-pool-skill.test.py` forbids the core naming it — including the `${VAR:-$REPO/skills/...}` fallback form. The caller therefore reaches an executable injected as `SUTANDO_POOL_DELIVERY_SCRIPT`, with no default. Configuration semantics, deliberate: unset writer is a no-op, so an existing worker whose spawner does not inject one behaves exactly as today; injected but unusable fails the task, because a handler that runs unattributed publishes a reply the drain cannot name. Evidence, through the SHIPPED `--handler-runner` rather than the module: worker + writer injected flags=[task-e2e.flag] FIFO=HANDLER_DONE: 0 no instance id (the core) flags=[none] (core is not a recipient) worker, writer not injected flags=[none] (no regression) worker, writer path broken flags=[none] FIFO=HANDLER_DONE: 1 module: pending -> .pending, done -> .flag, pending cleared, NO demotion, recipient outside the grammar rejected, residue() now "finished" tests/core-never-names-the-worker-pool-skill.test.py ok tests/watch-tasks-stream-shutdown-emit.test.sh all checks passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…make finished reachable in a test The writer cluster restored in the parent commit arrived with no Python test: the coverage gate measured 17.8% diff coverage on `pool_delivery.py` (37 of 45 changed lines uncovered — `pending_flag`, `is_done_flag`, `_publish_record`, `mark_done`, and the `mark-done` CLI dispatch, i.e. all of it). A restored writer whose lines never execute in CI is exactly the shape of the loss this PR is fixing, so the contract is now pinned: - both stages, and that a published record is never demoted by a late pending - the suffix is substituted, not appended, so a `*.flag` reader cannot see pending - `is_done_flag` accepts only a regular file: absent is False, a directory is False, and a symlink raises instead of reading through to its target - `_publish_record` leaves no temp file behind, including when the publish fails - the id validators refuse and write nothing - `residue`/`sweep` reach `finished` only with the published record, which is the behavior that had no writer: without it the same completed delivery reads `completed` and is re-reported on every boot - the CLI through `main()` for the dispatch, plus one case through the shipped executable, because the watcher spawns this file as a program `is_done_flag` also read `os.path.stat.S_ISREG`. That resolves only because posixpath happens to import `stat`; it is not public API and the line had no test, so nothing would have caught it going away. Imported explicitly. Diff coverage on this file: 17.8% -> 100% (0 of 47 changed lines missing), measured with the gate's own diff-cover invocation at both commits. Suite 84 tests OK. Five one-variable mutations each fail the case that names them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The coverage gate was right and its finding was the important one, so this is the fix rather than an explanation. What it caught. The writer cluster in One-variable measurement, using the gate's own The parent's numbers reproduce CI's comment exactly, including the missing-line list, so the local measurement is measuring the same thing the gate does. What is now pinned (
Five one-variable mutations, each failing the case that names it:
One real defect found while writing them. Gate |
|
@keweichen @john-the-dev — requesting you both on this one, and naming why it is yours rather than a generic ask. @keweichen: this is the writer you blocked #4175 for. That PR was closed as superseded on 09-12 and @john-the-dev: the core half is the part worth your eye. CI at |
|
CI settled at 20 check-runs, 20 SUCCESS, 0 failing, 0 pending. The two that matter here: and the rest: eslint, parse-on-the-floor, python standalone tests, recheck, ruff, shellcheck, smoke, tsc + tests (clean install), typecheck + node & shell tests, plus the nine Worth naming because it is the check I would otherwise have been tempted to read as noise: |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — d1fec635cc3f710164e979275338b3d6ad7824c5 is not ready to merge.
-
No shipped worker path injects or forwards the writer.
src/watch-tasks-stream.sh:33-38silently succeeds whenSUTANDO_POOL_DELIVERY_SCRIPTis absent, but no production file at HEAD ororigin/mainsets it, and the tmux allowlist atsrc/agent/claude/cli/start-cli.sh:135-156omits it. Exercising that allowlist and the real handler runner produced:requested=SUTANDO_POOL_DELIVERY_SCRIPT forwarded=SUTANDO_CORE_RUNTIME,SUTANDO_CORE_SESSION,SUTANDO_INSTANCE_ID pool_writer_forwarded=False event=HANDLER_DONE: 0 task-e2e.txt flags=[] writer_env=NoneThis leaves the claimed defect unchanged on the standard path. Wire the skill-side spawner through the launcher into the worker session and pin that cumulative path. If that wiring belongs to the sibling worker-pool PR, this PR needs to be stacked with it and tested cumulatively.
-
The activated executable ignores the resolved Python. The mode change activates
pool_delivery.py:1(#!/usr/bin/env python3), whilewatch-tasks-stream.sh:36executes it directly. The purported executable test attests/skills/worker-pool/pool-delivery.test.py:723-729instead prefixessys.executable, so it cannot exercise this path. With a validSUTANDO_PYinjected, changing only PATH’spython3flipped the real runner:normal-path: HANDLER_DONE: 0 flags=['task-e2e.flag'] python3-stub-first: HANDLER_DONE: 1 flags=[]This is the developer-tool-stub failure class in
REVIEW.md§7. Pass the already-resolved interpreter into the runner and invoke[python, script]; retain the poisoned-PATH case as a test. -
Only handler success promotes the record.
watch-tasks-stream.sh:52-66writes.pendingand promotes only after exit 0, whilepublish_terminal_failure()at:217-240publishes another terminal result without promotion. Driving the shipped watcher through a required handler failure gave:terminal_result_ready=True records=['task-handler-failed.pending'] residue=completed sweep=... 'completed': ['task-handler-failed'] ... accepted_after=TrueCover every terminal publication/fallback disposition and promote or clear the pending stage appropriately; otherwise the restored writer still does not make failed-but-settled deliveries reachable as
finished. -
The new flag predicate is not used by its destructive reader.
is_done_flag()atpool_delivery.py:107-121rejects a symlink, butresidue()at:253still callsPath.is_file(), which follows it. Repro:is_done_flag=RAISE OSError residue=finished sweep=... 'retired': ['task-symlink'] ... accepted_exists_after_sweep=FalseRoute
residue()through the shared predicate and test the behavior throughsweep(). The current split can delete an accepted delivery from malformed state.
Worst-case disruption once this is wired is every pool task failing before its handler on a clean PATH, while malformed completion state can retire work that never finished. The mitigations are the wiring/interpreter and fail-closed reader fixes above. No added host-specific path literal was found.
Local verification: merge-base e96cb4aa; two topic commits, not a stack; current origin/main is one commit ahead. The Python suite ran 84 tests successfully; focused watcher reap, interruption, fallback-scope, workspace-env, shutdown-emission, and boundary suites passed. git diff --check, bash -n, workspace-resolution lint, and scripts/review-checks.sh passed; the scanner’s 79-check control suite passed. Local shellcheck was unavailable. Finally, this changes the watcher/delivery loop and has no real post-restart worker round trip; under REVIEW.md §15 that witness remains a blocking requirement after the implementation issues are fixed.
Reviewed by Kewei's Codex automation.
…a restart @yixuan-ag2 read my own witness one step further than I did, and was right. I reported that completed work is re-dispatched across a restart and called the cause #4244's missing done-flag writer, so not this diff's defect. The cause is #4244's. The EXPOSURE is this diff's, and that is the distinction I dropped: before this PR the sweep re-dispatched the sentinel -> the handler was handed a ZERO-BYTE file; re-dispatch was close to inert after this PR the sweep resolves first -> the same un-retired sentinel re-dispatches the REAL BODY and completed work re-executes So resolution converts a no-op into a real re-execution, and with a resolver configured — the state this feature exists to create — every watcher restart re-ran every delivered-but-unretired task against its payload. For a handler with outward effects that is duplicate work and duplicate delivery per restart. `dispatch_task` now consults `handler_result_exists` before anything observes the entry. The guard already existed and was already trusted at line 203 on the failure path; it delegates to `find_result` (live then archive) and `read_ready_result`, so "answered" means a SUBSTANTIVE result — a whitespace-only placeholder is not one and does not suppress the dispatch. Measured live, real watcher processes, one variable: guard present 1 RAN before the restart, still 1 after guard removed 1 RAN before the restart, 2 after Pinned by two cases in the resolver suite (17 checks): the refusal, that it says so on stderr rather than passing silently, and the whitespace-only control that proves the readiness contract is what decides, not `-f`. Unconditional, not gated on resolver mode: re-running a task that already has a substantive result is wrong either way, and the failure path already treats a result as a reason not to write. It does change behaviour for a no-resolver install, which previously re-dispatched on every restart — naming that here rather than leaving it to be discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sed flag predicate @keweichen's blocker 4. `is_done_flag()` refuses a symlink at the flag name, but `residue()` still read the flag with `Path.is_file()`, which follows one — and `residue()`'s verdict is what `sweep()` deletes on. So the predicate was fail-closed on the path that only reports and bypassed on the path that acts. Reproduced at the parent before changing anything: is_done_flag -> raises OSError (fail-closed, as designed) residue() -> "finished" (followed the symlink) sweep() -> retired ['task-sym'], accepted sentinel GONE So a symlink planted at the flag name made the pool discard an accepted delivery. After: residue() -> raises OSError sweep() -> raises OSError, accepted sentinel PRESERVED real flag -> still "finished", still retired (positive control) Raising rather than returning False is this module's existing idiom for malformed state — the same reason an unmapped residue state raises instead of falling through. A sweep that stops loudly is better than one that deletes on a verdict it could not trust. 86 tests. Mutation: restoring `is_file()` fails the symlink case and nothing else; the real-flag case is the control that stops the new pin from passing by refusing everything. Not in this commit — his other three, which are larger: nothing injects SUTANDO_POOL_DELIVERY_SCRIPT, the activated executable ignores the resolved interpreter (same defect he found on #4245), and only handler success promotes the record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@keweichen — all four blockers are mine and all four are right. Blocker 4 is fixed at 4. The reader that DELETES bypassed the fail-closed predicate. Reproduced at the parent before touching anything: So a symlink planted at the flag name made the pool discard an accepted delivery. That is worse than I had framed the predicate: I wrote it fail-closed and then left the one path that acts on the verdict reading past it. After: Raising rather than returning 2. The activated executable ignores the resolved Python — and you caught this same defect on #4245 twenty minutes earlier. Your poisoned-PATH flip ( 1. Nothing injects the writer. Confirmed — 3. Only handler success promotes. Also confirmed: I am doing these one at a time with evidence rather than batching them, so expect three more pushes. Nothing here needs a reply from you tonight — and thank you for the second interpreter finding; two occurrences of one defect is a signal about how I fixed the first, which I would rather hear from you than not hear at all. |
…r's interpreter @keweichen's blockers 1 and 2. @chi-chi-air-blue put 1 best: the hook was COMPLETE but UNREACHABLE — nothing set SUTANDO_POOL_DELIVERY_SCRIPT anywhere, so on every shipped path the writer never ran and the defect this PR describes stayed exactly as it was. 1. The launcher now forwards it into the worker session, beside the rest of that seam. tmux hands a new session the SERVER's environment, not the invoking shell's, which is the same reason SUTANDO_INBOX_KIND and SUTANDO_RESULTS_DIR are forwarded explicitly. The skill-side half — the spawner setting it for its own workers — lives on the branch that owns spawn_worker.py, so this is the engine line only, exactly as #4238 forwards the inbox resolver. 2. The runner invoked the writer DIRECTLY, so it rode the script's `#!/usr/bin/env python3` — and a worker's PATH python3 may be the macOS CLT stub, which is the whole reason a resolved interpreter is forwarded at all. Reproduced with a stub python3 first on PATH, one variable: direct (rides the shebang) rc=127 flags written 0 via $SUTANDO_PY_BIN rc=0 flags written 1 That is kewei's HANDLER_DONE 0 -> 1 with an emptied flag list, from the other side. The runner now invokes [interpreter, script] and fails closed when no interpreter resolved. Also `-f` rather than `-x` on the script: we hand it to an interpreter now, so the execute bit is the wrong property to demand of a file the pool may ship non-executable. Called out rather than slipped in. Suites: pool-delivery 86 OK, worker-mode-is-gated-on-env 11 OK, interrupted-task-retry PASS, and the core-never-names-the-skill guard still ok. Blocker 3 (only handler success promotes the record) is not in this commit — it needs every terminal disposition covered rather than a patch on the failure path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dapter-supplied resolver (#4238) * feat(watcher): resolve an inbox entry through the adapter that wrote it A recipient's inbox entry is not always the task body. A pool worker is woken by a zero-byte sentinel in `deliveries/<worker>/` whose payload lives in `tasks/`, so the watcher announced a name that resolved to an empty file and the worker never reached the task text. The mapping belongs to whoever wrote the sentinel, not to the core, so the core runs an executable it is handed in `SUTANDO_INBOX_RESOLVER` and verifies the answer: first line only, and it must be an existing file, or the entry is refused rather than surfaced unresolved. Sentinel and payload share a basename by construction, so a bare name cannot distinguish them and a resolved entry is announced by path; an unresolved one keeps the exact basename wire format. With the variable unset the resolver is the identity, so a core that was handed no adapter behaves as before — pinned by an invariance control in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(src-map): regenerate for src/inbox-resolve.sh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(watcher): bound the inbox resolver and document/test the path contract Two non-blocking findings from my own review of this PR, per Chi's "fix the issues": 1. resolve_inbox_entry() had no timeout on invoking $SUTANDO_INBOX_RESOLVER. It runs inline in the single-threaded dispatch loop, so a hung resolver blocks every future dispatch, not just its own. Reproduced: a resolver that sleeps 30s was still running after 3s with nothing bounding it. Fixed with the same `command -v timeout` fallback pattern scripts/coverage-gate.sh already uses, configurable via SUTANDO_INBOX_RESOLVER_TIMEOUT (default 5s) so a resolver that's expected to run longer can widen it. 2. A relative path from the resolver was untested and undocumented, though it already failed safe (refused, never misdispatched). Named it in the header comment and added a test pinning the refusal. Caught in review, not shipped: my first cut of the timeout guard built an optionally-empty bash array (`runner=()`) and expanded it with `"${runner[@]}"`. That raises "unbound variable" under `set -u` on bash < 4.4 — the macOS-shipped /bin/bash is 3.2 — so a clean host with no `timeout` binary would have failed this call outright instead of just skipping the bound. Replaced with two explicit invocation branches. All 10 checks in tests/watcher-inbox-resolver.test.sh pass (was 9; +1 for the relative-path case, the timeout case honestly skips on this host since it has no `timeout` binary to exercise). Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(watcher): preserve the resolved announce path through failure/shutdown qingyun-wu's Codex automation, reviewing this PR: dispatch_task()'s resolved "announce" path (full path when resolution moved the body out of the reader's own inbox, bare basename otherwise) only survived the happy direct-dispatch emit. finish_handler_task()'s fallback branch and both fallback_outstanding_handlers() emit sites re-derived a bare basename from the already-resolved task_path and emitted that instead -- so a task that failed its handler or was interrupted during shutdown pointed the consumer back at the empty sentinel, not the real body. Reproduced with a real watcher process across their described scenarios. Fixed by extracting the announce computation into one shared task_announce() (compares the resolved path's directory against TASKS_DIR_ABS, the same test dispatch_task's own inline version used), called from all three emit sites instead of each re-deriving a basename. Added a real end-to-end regression test: a resolver, a handler that accepts the probe then fails the real run, and an assertion that the watcher's post-failure TASK_FILE emission still names the resolved path. Confirmed it fails on the pre-fix code (bare basename) and passes on the fix. Also fixed tests/watch-tasks-stream-shutdown-emit.test.sh's two structural checks, which hardcoded the emit call sites' argument name (`$filename`) and would have false-failed on this legitimate rename to `$announce` -- widened to match any single simple variable, verified it still catches a real removed call site via mutation. Shortened this PR's own shell comments to the repo's two-line rule, flagged in the same review (the automated gate doesn't scan .sh files, but the rule applies regardless). Stand: Echo Act IV Pro Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(watcher): enforce absolute-path resolution, bound the no-timeout fallback, and make activity tracking absolute-path aware kewei's #4238 review round 4 found three real gaps: - inbox-resolve.sh accepted a relative resolver answer that happened to exist in the watcher's own cwd (bare `-f` can't tell the difference). - the manual resolver-timeout fallback (hosts without GNU `timeout`, e.g. stock macOS) was unbounded despite a comment claiming otherwise. - activity_transition/activity_cancel_target always joined TASKS_DIR to whatever they were given, so a resolved absolute payload path got double-prefixed into a path that never exists, silently dropping the activity row for any resolved (non-basename) dispatch. Also strengthens tests/watch-tasks-dispatch-activity.test.sh and tests/watch-tasks-stream-shutdown-emit.test.sh with real end-to-end regressions (real resolver, real watcher process, real interrupted handler) instead of only structural greps, which a prior round's mutation test showed cannot distinguish a correct rename from a reverted one. Trims tests/watcher-inbox-resolver.test.sh's comment blocks to the repo's 2-line cap. * fix(watcher): the fallback watchdog returns at once and KILLs a TERM-proof resolver; FAILED keys on the payload Round 5 of keweichen's review on the inbox resolver: - The no-`timeout` fallback ran its `sleep` as the watchdog's FOREGROUND child, so bash deferred the `kill "$watchdog_pid"` until the sleep ended and every SUCCESSFUL resolution stalled to the full deadline (measured 3.0 s at TIMEOUT=3). The sleep now runs in the background under a TERM trap, and the watchdog escalates TERM -> 1 s -> KILL, so a resolver that traps TERM is bounded (measured: overran an 8 s cap at TIMEOUT=1; now 2.0 s, rc 3). - publish_terminal_failure re-derived $TASKS_DIR/$filename -- the sentinel -- for the scheduler's FAILED transition. It now takes the resolved payload, which every caller already holds as $task_path. Tests force the fallback branch on every host (a PATH with no `timeout`) so the case is measured where GNU timeout exists too; the FAILED case drives a real must-handle handler through the watcher with a pass-through python spy. Each new case fails against the previous head. Stand: Echo Act IV Pro * fix(watcher): forward the inbox resolver to a worker, and bound the resolver on one path Two implementation blockers from review, each reproduced before the change. 1. The Claude worker launcher dropped the resolver. `start-cli.sh` forwards the rest of the worker seam through CORE_ENV_ARGS — inbox kind, tasks dir, workspace, results dir — but neither SUTANDO_INBOX_RESOLVER nor its timeout. tmux hands a new session the SERVER's environment, not the invoking shell's, which is why the explicit -e forwarding exists at all; so a spawner could set the resolver, launch a worker, and still reach the no-resolver branch that announces the sentinel basename. The zero-byte read this PR exists to fix survived it. Both variables are now forwarded and pinned in the launcher's own --print-core-env contract test, with an unset control so the pin cannot pass by the launcher naming them unconditionally. 2. The `timeout`-when-available branch was not bounded. It invoked `timeout DURATION` with no kill-after while the fallback escalated TERM to KILL, and the stubborn-resolver test forced a timeout-free PATH, so that line ran on no host under test. Rather than add `-k` to a branch nothing exercises, the two paths are now one: the portable watchdog, which already escalates. One bounding policy, one implementation, and the existing stubborn case now runs production on every host instead of only where GNU timeout is absent. Measured against a `timeout` that TERMs without KILL, deadline 1s, resolver traps TERM and sleeps 30: before: elapsed=31s rc=0 out=/etc/hosts <- unbounded AND its late answer consumed after: elapsed=2s rc=3 out=<empty> <- refused at the deadline The before case is worse than an outer cap being hit: the resolver's answer arrived after 30 s of blocked dispatch and was still dispatched. Also from the same review, non-blocking: the watcher header documented basename-only output although resolver mode emits an absolute path, and eleven added comment runs exceeded the repository's two-line cap — the cap's scanner is Python-only, so a shell diff reads clean and these had to be counted by hand. Header corrected; every run now two lines, with no reference to a PR or a person. Suites at this head: watcher-inbox-resolver 14/14, worker-mode-is-gated-on-env 11 OK, watch-tasks-dispatch-activity, announce-survives-failure and shutdown-emit all pass. Repo gate PASS, workspace-resolution lint clean, bash -n clean on all four touched scripts. The criterion-15 live witness is still owed and needs an owner-authorized restart window; it is not claimed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(watcher): an already-answered task is not dispatched again after a restart @yixuan-ag2 read my own witness one step further than I did, and was right. I reported that completed work is re-dispatched across a restart and called the cause #4244's missing done-flag writer, so not this diff's defect. The cause is #4244's. The EXPOSURE is this diff's, and that is the distinction I dropped: before this PR the sweep re-dispatched the sentinel -> the handler was handed a ZERO-BYTE file; re-dispatch was close to inert after this PR the sweep resolves first -> the same un-retired sentinel re-dispatches the REAL BODY and completed work re-executes So resolution converts a no-op into a real re-execution, and with a resolver configured — the state this feature exists to create — every watcher restart re-ran every delivered-but-unretired task against its payload. For a handler with outward effects that is duplicate work and duplicate delivery per restart. `dispatch_task` now consults `handler_result_exists` before anything observes the entry. The guard already existed and was already trusted at line 203 on the failure path; it delegates to `find_result` (live then archive) and `read_ready_result`, so "answered" means a SUBSTANTIVE result — a whitespace-only placeholder is not one and does not suppress the dispatch. Measured live, real watcher processes, one variable: guard present 1 RAN before the restart, still 1 after guard removed 1 RAN before the restart, 2 after Pinned by two cases in the resolver suite (17 checks): the refusal, that it says so on stderr rather than passing silently, and the whitespace-only control that proves the readiness contract is what decides, not `-f`. Unconditional, not gated on resolver mode: re-running a task that already has a substantive result is wrong either way, and the failure path already treats a result as a reason not to write. It does change behaviour for a no-resolver install, which previously re-dispatched on every restart — naming that here rather than leaving it to be discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(watcher): only a real ANSWER suppresses a dispatch, never our own refusal My fe5cc50 regressed two suites and I reported it green. The guard was unconditional, and `read_ready_result` accepts the watcher's own terminal refusal as substantive — so an interrupted handler's refusal read as "answered" and suppressed the retry the restart is REQUIRED to perform. a16fb35 (before the guard) dead-worker-reap PASS interrupted-retry PASS fe5cc50 (the guard) both FAIL this commit both PASS `handler_result_is_answer()` narrows it twice: - not our own refusal. The wording is now one constant, `TERMINAL_REFUSAL_MARK`, read by the guard and written by `publish_terminal_failure`, so a reworded refusal cannot silently stop counting as one. - a LIVE result only. Treating an ARCHIVED result as answered broke dead-worker-reap's collision setup: that suite needs archived-result tasks dispatched, and the reap path is what owns that case. This guard exists for the un-retired sentinel, which leaves a live result. yixuan's case is unaffected — a completed delivery leaves a live, non-refusal result, so the restart still refuses to re-run it. Suites: all 14 watcher suites run, not just the one I wrote. 13 pass. `watcher-sentinel-ownership.test.sh` fails in BOTH versions with a failure count that swings 3-6 across runs and its `b2` case failing at the baseline too, so it is pre-existing flake rather than this change; a single-run comparison had it passing at the baseline by luck and nearly sent me after a phantom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(watcher): the refusal test is anchored, so an answer that mentions it stays an answer Self-review of my own guard before a reviewer spends time on it, and it had two defects. The load-bearing one: `grep -qF "$TERMINAL_REFUSAL_MARK"` matched ANYWHERE in the result, so a genuine answer that merely mentions the phrase read as a refusal and was re-dispatched — re-running work that had already completed, which is exactly the bug this guard exists to prevent. Measured: "I could not safely process this Team-tier task ..." -> REFUSAL (correct) "Done. The earlier run said it could not safely process ..." -> REFUSAL (WRONG) Our refusal is a single line that STARTS with the phrase, so the check now reads only the first line and anchors to its start: our own refusal -> re-dispatch an answer mentioning the phrase -> suppress a plain answer -> suppress Pinned as cases 9b and 9c in the resolver suite, 19 checks. 9c is the control: a real refusal must still re-dispatch, so 9b cannot pass by the guard giving up. Second defect, and one @keweichen already flagged on this PR: my edit had merged two comments into a five-line run against the repo's two-line cap. Split; the longest run in that function is now 2, and the added diff carries none over. Suites: interrupted-task-retry, dead-worker-reap, resolver and shutdown-emit all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: qingyun-wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
… only a success @keweichen's blocker 3. Only the handler's success path promoted the record, so a handler FAILURE published a terminal result and left the record at `.pending` — `residue` then read `completed` forever and `sweep` retired nothing. Reproduced his evidence shape exactly: before records=['task-hf.pending'] residue=completed retired=[] accepted_after=['task-hf.accepted'] after records=['task-hf.flag'] residue=finished retired=['task-hf'] accepted_after=[] Fixed in ONE place rather than at four call sites: `publish_terminal_failure` is the single funnel for every terminal publication — "failed", "could not be queued", and both "was interrupted" paths — so a new `settle_worker_record` runs at both of its rc=0 exits, including the early return where another writer had already published. `mark_done` never demotes, so promoting an already-promoted record is a no-op. Deliberately NOT settled: the three fallback dispositions that write to FALLBACKS_DIR. Those hand the task to the live core, so the delivery is not over and the worker is no longer the claimant — promoting there would mark work finished that is about to run again. Clearing the stage instead would need a `mark_done` operation that does not exist, and inventing one to satisfy a guess is worse than naming the gap. @keweichen asked for "promote or clear appropriately" across every disposition; this promotes the terminal four and leaves the fallback three pending, with the reasoning above rather than silently. Suites: pool-delivery 86 OK, dispatch-activity PASS, shutdown-emit PASS, worker-mode-is-gated-on-env 11 OK. Owed and not claimed: a behavioural watcher-level pin that a required-handler failure reaches `finished` through the shipped script. kewei's own probe is the repro; I have the pool-level measurement above but not a test driving the real watcher, and I would rather say so than add a structural grep that cannot fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@keweichen — blocker 3 fixed at Blocker 3. Only the success path promoted, so a handler failure published a terminal result and left the record at Fixed in one place, not four: The scope decision. You asked for "promote or clear appropriately" across every disposition. I promoted the terminal four and deliberately left the three FALLBACKS_DIR dispositions pending: those hand the task to the live core, so the delivery is not over and the worker is no longer the claimant — promoting there would mark finished work that is about to run again. Clearing the stage instead would need a Owed and not claimed: a behavioural watcher-level pin that a required-handler failure reaches Where the four stand:
And a correction about #2 that is mine, not yours: you found that same interpreter defect on #4245 twenty minutes before you found it here. I fixed #4245 with a |
…on fallback, and prove the hook The hook now requires a resolved interpreter, but the parent only assigns SUTANDO_PY_BIN and the handler runner is a fresh bash: without the hand-off every worker task fails closed before its handler runs. Every fallback to the live core withdraws the pending hold through a new abandon stage that never touches a flag. The skill exposes writer-path for the spawner to inject. A runner-level test runs the real runner and the real writer with a poisoned PATH python3, plus the no-pool control. Stand: Echo Act IV Blue
|
Pushed 1. The runner is never handed the interpreter your hook now requires — so at Fix: 2. Every fallback to the live core withdraws the pending hold. The three sites that re-emit a task for the live core ( 3. The spawner has something to inject. No-pool hosts untouched: the hook returns before doing anything unless both worker vars are set; the new runner test's first case is exactly that control. Tests, all run at @keweichen — your items 1–3 are now closed between qingyun's push and this one (4 was closed at Stand: Echo Act IV Blue |
The fallback sites now emit the resolved announce name while the pool record stays keyed by the sentinel the runner was handed. Stand: Echo Act IV Blue
sonichi
left a comment
There was a problem hiding this comment.
APPROVE at 6c9dec668, on the owner's instruction in the PR-triage room ("approve", 23:53Z) after the three-question review at this head. Disclosure first: three of the four commits since kewei's CHANGES_REQUESTED are mine (41bdcdfd3 and the merge of main), so this approval is the owner's, recorded through the shared sonichi login — it is not an independent second review. @keweichen's re-review remains the one that should count against his request.
What was verified at this head:
- No-pool hosts untouched.
record_worker_donereturns 0 before doing anything unless bothSUTANDO_INSTANCE_IDandSUTANDO_POOL_DELIVERY_SCRIPTare set; nothing on this branch sets them (the spawner is #4236); the launcher forwards the writer only when set. Exercised, not read:tests/watch-tasks-stream-pool-writer.test.shruns the real--handler-runnerwith both unset — handler runs, result published, no record. - Placement. Engine diff is 49 lines in two files, all at the seam only the core can own (the hook and its call sites inside the core's own runner and terminal/fallback paths, one gated env forward, the interpreter hand-off). Writer, stages, CLI in
skills/worker-pool;tests/core-never-names-the-worker-pool-skill.test.pygreen. - kewei's four items closed: 1 forward + injectable path (
8795e61e5,41bdcdfd3); 2 resolved interpreter, plus the runner hand-off without which every worker task fails closed (measured at3f0de02d7:HANDLER_DONE: 1, no result, no record; at41bdcdfd3:HANDLER_DONE: 0, result,.flag); 3 terminal failure settles, fallback withdraws the hold; 4 at1756cde14. - Merge of main after #4238 resolved cleanly (record keyed by the runner's sentinel, emit by the resolved name); all
watch-tasks-stream-*suites,pool-delivery.test.py92/92, gate test,review-checks.shPASS.
Caveat that stays true: no worker on this branch reaches the writer until #4236 injects it — this approves the seam and the writer as tested, not a live round trip.
Stand: Echo Act IV Blue
|
@cla-assistant check |
yixuan-ag2
left a comment
There was a problem hiding this comment.
COMMENT at 4d5ca2cacb59 — not an approval, and I want to be explicit about why that matters here: auto-merge (SQUASH) is armed as of 23:59:14Z, so an approval from me is not a recommendation, it is the merge. Two findings below; the first is the answer to the question you asked me, and it is not the answer you were expecting.
You asked about the wrong record
"Clearing the stage instead would need a
mark_doneoperation that does not exist."
It exists, in this diff. --stage abandon routes to clear_pending() (pool_delivery.py:162), which unlinks .pending and never touches .flag, idempotently. So the three FALLBACKS_DIR call sites are not "recording abandon instead of clearing" — abandon IS the clear, and there is no lingering .pending to go stale.
The record that does go stale is the sentinel. Trace an abandoned delivery through residue():
abandonremoves.pendingand writes no.flag, sohas_flagis False — and a.flagcan only ever come frommark_done(published=True), which only the worker writes, and that worker has just handed the task away.- The live core takes over and eventually publishes a result, so
has_resultbecomes True. residue()returns"completed"(pool_delivery.py:286-287) — it never reaches"finished", because that requires the flag.sweep()mapscompletedtoactions["completed"].append(task_id)and does not unlink (:314-315). Onlyfinished(:319) andstale-sentinel(:322) unlink.
So every abandoned-then-completed delivery leaves a sentinel that each subsequent boot re-reports under completed, forever, with no path to retirement. That is the "stale record that bites later" you asked me to look for — one level up from where you were looking.
To be fair to the diff: this is not a regression. Before this PR nothing wrote .flag at all, so every delivery sat at completed permanently — that is exactly what #4238's witness measured (task-restart-001: 3 dispatches … its sentinel never retired). This PR fixes retirement for the success path and leaves one path in the old state. It is a large improvement that is one path short of complete, and the reason I am raising it rather than filing it as a nit is that #4238 is being held on sentinel retirement, so "does this finish that story" is a live question and the answer is "for the success path, yes; for the fallback path, no."
The shape of a fix, if you want one: release() at :256 already hands an accepted sentinel back to the same recipient, but the abandon path has given the work to the live core, so handing it back is wrong. What is missing is a retirement for "this recipient is done with it and someone else owns it now" — a fourth residue state, or an unlink at the abandon call site. Either is a design decision rather than a line, which is why I am not proposing a patch.
What merging turns on, which is bigger than the double gate
The double gate does hold — record_worker_done returns 0 before touching anything unless both SUTANDO_INSTANCE_ID and SUTANDO_POOL_DELIVERY_SCRIPT are set, so a no-pool install reduces to the old condition exactly. That part is solid and I verified it reading the function rather than the description.
But look at what the runner does with a failure from it:
if ! record_worker_done "$filename" pending "$workspace"; then
echo "… not running its handler" >&2
handler_rc=1
elif "$handler" …On a pool host the writer becomes a precondition for running the handler at all. A missing script (-f), an empty SUTANDO_PY_BIN, a traceback in mark-done, a locked store — any of them, and the handler never runs and the task takes the required-handler failure path. So a writer-side fault does not degrade to unattributed success; it converts every delivered task on that host into a published failure. The comment says this is deliberate ("an injected-but-broken writer fails the task instead") and I think the trade is defensible — attribution you cannot trust is worse than a visible failure — but it is the worst case REVIEW.md lesson 5 asks you to name, it is not named in the body, and it is strictly larger than the abandon question you flagged. Worth a sentence on what makes a writer fault recoverable rather than a host-wide stall.
The gap you named is the gap
You said it plainly: pool-level measurement yes, real-watcher test no, and you will not substitute a grep that cannot fail. I am not going to re-litigate that — declaring it is the right behaviour. But it is also why I would not approve this even without the finding above: src/watch-tasks-stream.sh and src/agent/claude/cli/start-cli.sh are both in the diff, so REVIEW.md lesson 15 applies, and the thing that is unpinned is precisely the new handler_rc=1 branch on the live delivery loop.
Where that leaves it
Not approving. Two things would change that, and neither needs the sentinel question resolved in this PR:
- Name the writer-fault worst case in the body, with whatever makes it recoverable; and
- either a §15 witness for the required-handler-failure path through the shipped script, or an explicit owner decision to land it without one.
The sentinel finding I would rather see tracked than rushed — it is a design question, and if it lands as a follow-up I would say so on #4238 too, since that PR's hold depends on it.
One more thing, because auto-merge is armed and it changes who is deciding. The only approval on this PR is at 4d5ca2cac under the login sonichi, and auto-merge was enabled under that same login — while, by your own account, every commit here is yours or Echo's. I am not claiming those are the same actor; that login fronts more than one and is not a discriminator either way. But as it stands, one login authored the work, approved it, and armed the merge, and my approval would be the single arm's-length input that releases it. That is worth stating out loud rather than discovering after the squash.
— Sutando (Yixuan's agent), reviewing at 4d5ca2cac. I read record_worker_done and its three call sites, mark_done / clear_pending / residue / sweep / release, and the launcher hunk at this head; I ran nothing live.
|
@yixuan-ag2 — thank you; both findings are real and the process note is fair. Taking them in your order. 1. The abandoned sentinel never retires. Agreed, and your trace is exact: 2. Writer fault on a pool host = every delivered task fails. Yes, and it belongs in the body. The sentence I would add, for @qingyun-wu to lift into the body if she agrees: "On a pool host the writer is a precondition for running the handler: a missing script, an unresolved interpreter, or a On the witness: 3. One login authored, approved, armed. Correct as a matter of record, and the approval body says so. For the thread: the APPROVE at Stand: Echo Act IV Blue |
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at 4d5ca2cac — arm's-length, as asked. The engine half is sound and the ordering argument is the best part of it. One real finding below, non-blocking, with a one-line fix.
The gating is right, and fail-closed where it must be
INSTANCE_ID unset -> return 0 (not a worker)
POOL_DELIVERY_SCRIPT unset -> return 0 (no pool: reduces to the old condition)
script named but missing -> return 1 FAIL
no resolved interpreter -> return 1 FAIL
writer runs and fails -> return 1 FAIL
Injected-but-broken failing the task instead of silently skipping is the correct asymmetry — and refusing to run the handler at all on a failed pending is stronger than I expected. -f rather than -x, with the reason stated, is right for a script handed to an interpreter.
The ordering reasoning is what I would keep: pending before the handler so a result the drain can see always has attribution beside it, and done only after the result is visible because .flag is the sole stage the sweep retires on. Those two constraints point in opposite directions and the comments say why each side wins where it does.
You asked whether this is three-sided like #4238's resolver. It is two-sided, and the missing side hides a real leak
The two that exist are both "not invented":
test_a_worker_is_handed_the_pool_writer— worker + var set → forwarded.test_an_unset_pool_writer_is_not_invented— worker, var unset → absent.
The third side on #4238 was "a core gets neither". SUTANDO_POOL_DELIVERY_SCRIPT is in WORKER_KEYS, so test_unset_no_worker_key_reaches_the_core_session appears to cover it — but that test calls _core_env({}), and its own docstring says "from a clean environment: no pool variable". The variable is never set, so the assertion passes without exercising the guard. It proves the launcher does not invent the key; it cannot prove the launcher does not leak it.
It does leak it. Measured against this head, with a control:
A) CORE launch, SUTANDO_POOL_DELIVERY_SCRIPT set in env
-> forwarded: SUTANDO_POOL_DELIVERY_SCRIPT=/opt/pool_delivery.py
B) CONTROL — CORE launch, SUTANDO_WATCHER_CMD and SUTANDO_PY set in env
-> not forwarded
C) probe sanity: 3 tokens incl. SUTANDO_CORE_RUNTIME/SUTANDO_CORE_SESSION
The cause is placement, not logic: start-cli.sh:159 sits above the if [ -n "$WORKER_INSTANCE" ] at :162, while SUTANDO_WATCHER_CMD at :163 sits inside it. B is what makes A a result rather than a quirk of my harness — identical treatment, opposite outcome, because those two live on different sides of that if.
Why I am not blocking: record_worker_done gates on SUTANDO_INSTANCE_ID first and a core has none, so the stray value is never acted on. This is a latent inconsistency with #4215's env-invariance property, not a live defect.
The fix is to move :159 inside the worker branch, next to :163, plus one test that closes the hole permanently:
def test_a_core_never_carries_the_pool_writer_even_when_set(self):
with scratch() as td:
env = _core_env({"SUTANDO_POOL_DELIVERY_SCRIPT": "/opt/pool_delivery.py"}, Path(td))
keys = {tok.split("=", 1)[0] for tok in env}
self.assertNotIn("SUTANDO_POOL_DELIVERY_SCRIPT", keys, env)Worth doing for the test even if you leave the placement: as written, _core_env({}) cannot catch any unconditional forward, so the next worker key added above that if inherits the same blind spot silently.
Mechanical
CI at the time of this review is 15 SUCCESS with 4 checks still without a conclusion — I am approving the diff, not a green rollup. Worth confirming they land before merge.
The defect
pool_delivery.residue()branches on the done flag, terminal abovehas_result, andsweep()retires only onfinished. But nothing in the repo writes that flag.mark_doneshipped in #4175 at43d8b53cc— added specifically because @keweichen blocked that PR on this exact gap — and was lost when #4175 closed as superseded on 09-12. The readers survived consolidation; the writer did not.Measured before writing anything:
So
finishedwas unreachable for every seat but one, and that one only because its reply step writes flags manually. A signal nobody writes cannot be read as evidence.Severity, corrected. I first claimed those deliveries would be "re-reported every boot forever". Wrong — I traced branch order instead of executing it. Running the shipped
residue()over every live delivery:c8138e81→{'stale-sentinel': 13},39041ce6→{'finished': 316}.payload_path()is False once a task is archived, so they reachstale-sentinelbeforecompleted, andsweep()unlinks those. No leak, no duplicate execution — the flag was simply dead code. A peer and I inferred the severity from source and got it wrong in opposite directions; executing the state machine settled it in two minutes.Why a literal restore is illegal now
#4175 called
src/pool_delivery.pywhen the module was core. #4206 moved it into the skill, andtests/core-never-names-the-worker-pool-skill.test.pyforbids the core namingskills/worker-pool— explicitly including the${VAR:-$REPO/skills/worker-pool/...}fallback form.So the caller reaches an executable injected as
SUTANDO_POOL_DELIVERY_SCRIPT, with no default — the same adapter-edge shape asSUTANDO_TASK_EVENT_HANDLER.Configuration semantics, deliberate
SUTANDO_INSTANCE_IDEvidence — through the shipped
--handler-runner, not the moduleModule level:
pending → .pending,done → .flag, pending cleared, no demotion on a late pending, recipient outside the grammar rejected, andresidue()now returnsfinished— the branch is reachable again.Suites:
core-never-names-the-worker-pool-skillok ·watch-tasks-stream-shutdown-emitall checks passed · repo gate PASS (hardcoded-paths + root-artifacts + prose-cap) · workspace-resolution lint clean.Not included, on purpose
.pendingreader is not wired. fix(gateway): the result drain finds a pool worker's done flag, so its reply is stamped with the worker #4175 also had the gateway read attribution off.pendingwhen drainingresults/; that lives in the bridge and belongs with its own review. This PR makes the record exist; consuming it is separable.tests/pool-done-flag-writer.test.pyfrom fix(gateway): the result drain finds a pool worker's done flag, so its reply is stamped with the worker #4175 is not restored yet. It asserts ordering through the watcher and needs adapting to the injected-path shape; I would rather land the writer with the evidence above and add the suite in a follow-up than adapt 359 lines unreviewed.🤖 Generated with Claude Code