Repository navigation
feat(pool) [5/5]: the router pass — resolve one task, write the deliveries - #4110
qingyun-wu wants to merge 23 commits into
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/feat/spawn-worker-launcher...HEAD, staged and unstaged changes
Summary
skills/worker-pool/scripts/pool_route_handler.pyLines 125-134 125 d = _ws(workspace) / "logs"
126 d.mkdir(parents=True, exist_ok=True)
127 with open(d / "pool-route-handler.log", "a", encoding="utf-8") as fh:
128 fh.write(time.strftime("%Y-%m-%dT%H:%M:%S ") + line.rstrip() + "\n")
! 129 except OSError:
! 130 pass
131
132
133 def main(argv=None) -> int:
134 p = argparse.ArgumentParser(add_help=False)Lines 282-297 282 try:
283 out = rt.route(ws, task, roster)
284 except rt.RouterRefused as e:
285 return _defer(ws, task_id, f"refused: {e}")
! 286 except Exception:
! 287 _log(ws, f"{task_id}: crashed:\n" + traceback.format_exc())
! 288 return _defer(ws, task_id, "crashed (traceback logged)")
289 if out.get("failed"):
290 # Unreachable via the probe, which declines these; kept so a direct
291 # caller cannot turn an unknown name into a delivery.
! 292 print(json.dumps(out), file=sys.stderr)
! 293 return DECLINE
294 _drop_marker(ws, task_id)
295 _log(ws, f"{task_id}: delivered={out.get('delivered')} "
296 f"already={out.get('already')} skipped={out.get('skipped')} -> 0")
297 return 0skills/worker-pool/tests/pool-route-handler.test.pyLines 200-209 200
201 def loader(_ws):
202 try:
203 last[0] = next(seq)
! 204 except StopIteration:
! 205 pass
206 return last[0]
207 return unittest.mock.patch.object(h.pr, "load_roster", side_effect=loader)
208
209 def test_a_roster_swapped_mid_run_does_not_change_the_delivery_set(self):Lines 487-495 487 blind.mkdir(parents=True)
488 os.chmod(blind, 0o000)
489 self.addCleanup(os.chmod, blind, 0o755)
490 if os.access(blind, os.R_OK):
! 491 self.skipTest("the mode did not take effect (running as root?)")
492 self.roster()
493 self.task_file("task-1", channel_id="!room:x")
494 d = h.retry_dirs(self.ws)[1]
495 d.mkdir(parents=True, exist_ok=True)Lines 531-539 531 d.mkdir(parents=True)
532 os.chmod(d, 0o000)
533 self.addCleanup(os.chmod, d, 0o755)
534 if os.access(d, os.R_OK):
! 535 self.skipTest("the mode did not take effect (running as root?)")
536 self.assertEqual(h.main(["--workspace", str(self.ws), "--parked", "task-1"]),
537 h.PARKED_UNKNOWN)
538 self.assertGreater(h.PARKED_UNKNOWN, 1) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested (formal REQUEST_CHANGES is unavailable on this identity's own PR). Reviewed the child layer against #4109 and its bundled #4106 delivery implementation; 21 router and 42 delivery tests plus the added-path scan pass.
Three blocking integration cases remain:
- A router paused after find() can race another pass and a worker claim. When it resumes, O_EXCL recreates task-x beside task-x.claimed, and a second production pd.claim succeeds, replacing the first claim. Serialize/fence the whole transition; O_EXCL on one of two mutable names is insufficient.
- The documented admitted shape source=room, channel_id=!abc:ag2.space resolves to core despite a room:!abc:ag2.space binding. The test supplies the prefixed binding key as channel_id, so it misses real envelope normalization. Define one source-key owner and test the documented envelope.
- Every member of a set receives the same task id. pool_delivery.result_path is global by task id, and payload archival is global too: the first completion consumes the shared payload and multiple answers share one result path. Implement the specified per-recipient child identities/parent grouping before fan-out.
The race and source-key failures were exercised through the production router in scratch workspaces. The result-path collision is directly established by the router and delivery path functions; I did not run external task effects. Worst case: duplicate execution, wrong recipient or a lost sibling answer. Parent delivery/bindings findings also remain applicable.
Reviewed at 499b39f5c8a1d6a09f652eb33cc4a9ca009b89fb. No contributor branch or live runtime was changed.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at be393f1f7644ea6d4f76682c63e9cee7f95780c0 (formal REQUEST_CHANGES is unavailable on my own PR). The new handler introduces two blocking composition failures, detailed inline:
explicit worker, good roster: handler probe=0; production dispatch queues it
same task, corrupt roster: probe=3; production dispatch emits CORE_EVENT:task-x.txt
handler accepts tasks/task-handler.txt: rc=0, sentinel created
recipient read_payload: None; sweep: stale=[task-handler], sentinel removed
JSON-payload control: payload readable; sweep ready=[task-x]
The dispatch probe executes the current shell function with scratch inputs and stubbed event/queue emitters; it is not a real agent round trip. A declined probe is a live-core event, so absent/corrupt routing state must not decline addressed work. The adapter and delivery reader also need one canonical payload contract; creating a .txt sentinel alone does not bridge .txt envelopes to the reader's .json payload.
The prior three findings remain: src/pool_router.py:53-60 still permits two successful production claims in the find/create/claim/recreate interleaving; :80 still routes source=room/channel_id=!x to core while the prefixed-channel control reaches the bound worker; :90-92 still fans out one global task/result identity. Reproduced the race and source-key failure at this head. The shared result-path collision follows directly from the unchanged path functions. Keep those existing threads open; do not copy routing policy into the handler to patch symptoms.
21 router, 51 delivery and 9 handler tests pass. Current parent roster code and bundled delivery code inspected; parent blockers remain. Child/cumulative added-path scans and hosted CI/CLA pass, but green tests miss these behaviors. Worst case is wrong-recipient execution, duplicate work or silently discarded delivery. Real post-restart end-to-end acceptance remains required. No contributor branch or live runtime was changed.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 0deacfa8 (COMMENTED because this login authored the PR). The inherited .txt payload mismatch is resolved: production handler returns 0, read_payload returns the admitted header text, and the recipient sweep reports ready with the sentinel intact. The revised unknown-name-to-core and non-live-target delivery rules match the current design; the old PR-body rules should be refreshed.
These existing blockers remain reproducible:
- [P1] Duplicate claims —
src/pool_router.py:53-58. Pause writer A afterfind(None); production writer B delivers and the recipient claims; resume A's exclusive create. It succeeds on the now-free pending name. A second productionclaimsucceeds and overwrites the first.claimedpath. The both-name lookup and publication/claim transitions need one shared arbitration contract; the current EEXIST test does not cover a claimant freeing the name. - [P1] Source-key mismatch —
src/pool_router.py:79-80andsrc/pool_route_handler.py:50. With bindingroom:!x,{source: room, channel_id: !x}routes to core; changing only the channel field to the syntheticroom:!xreaches the worker. Derive the binding key from the actual admission shape in one shared owner. - [P1] Fan-out completion identity —
src/pool_router.py:91-93. Each member still gets the same task id and therefore the same result path and payload. One finish can remove the other's unread payload; two result publications overwrite one reply. Define distinct delivery/result identity plus shared-payload retention and test both completion orders and restart. - [P1] Missing/corrupt roster declines to core —
src/pool_route_handler.py:47-49. The same explicitly addressed task probes 0 with a valid roster and 3 without it;watch-tasks-stream.sh::dispatch_taskemits the task to the core on 3. Missing routing state is still required to refuse the pass, unlike a known roster containing an unknown target. Preserve that distinction across probe and execution failures.
Full child and inherited diff inspected. Delivery 51, router 20, handler 9 and bindings 22 tests pass. Child and cumulative path scans pass; the forbidden-path control fails. Scratch merge with current roster parent 1785e19c is conflict-free. Merge parents #4106/#4109 first and rerun after synchronization; their unresolved blockers remain relevant. Worst case on activation is duplicate effects, wrong-recipient execution or lost replies. No live round trip was performed; these are isolated production-function probes.
Reviewed by Qingyun's Codex automation.
There was a problem hiding this comment.
Changes requested at db054bef (COMMENTED because this login authored the PR). Both new fixes are verified: lane-encoded sentinels parse, and a gateway-shaped task with source/channel fields below task: changes from probe=3 on 0deacfa8 to probe=0 at HEAD. The inherited .txt payload fix also remains verified: the handler delivers, the reader returns the original header text, and the sweep keeps it ready.
The existing blocking threads remain:
src/pool_router.py:53-58: after writer A observes absence, B delivers and claims, then A recreates the pending name. Two productionclaimcalls still succeed on the same claimed path. Arbitrate publication and claim across both names.src/pool_router.py:91-93: fan-out still assigns one global payload/result identity to every recipient. The first archive can strand the sibling and two results collide. Add per-recipient completion identity and shared-payload retention.src/pool_route_handler.py:61-63: missing routing state still returns 3 for an explicitly addressed task, versus 0 with its valid roster; the watcher treats 3 as a core event. Refuse without fallback on unreadable routing state, including execution after probe.
Full child diff, bundled reader, parent roster and surrounding dispatch reviewed. Delivery 52, router 20, handler 11 and bindings 22 tests pass. Current-parent scratch merge is conflict-free; child/cumulative path scans pass and forbidden-path control fails. Production probes reproduce the race and absent-roster routing; the completion collision follows from the unchanged shared path functions.
Update after #4037 advanced during this review: b10b7661 now explicitly uses raw room IDs as binding keys. That design choice matches the handler's passing gateway-shape test, so I withdraw the earlier source-key item as a runtime blocker. Please refresh pool_roster.py's prefixed-key documentation and the router fixtures to the chosen shape; this is a non-blocking consistency follow-up.
The reported two-reply live run on #4105 is useful happy-path evidence, but does not test these races, failure states or fan-out completion. Worst case on activation remains duplicate effects, wrong-recipient execution or lost replies. Parent #4106/#4109 blockers remain applicable; merge parents first and rerun after synchronization. No contributor branch or live runtime changes.
Reviewed by Qingyun's Codex automation.
1785e19 to
8fa4289
Compare
db054be to
f2f53c8
Compare
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at f2f53c83, now based on #4108. The router/handler source is unchanged from reviewed db054bef; inherited delivery now recognizes a terminal done-flag after result drain. Reran 20 router, 11 handler, 24 bindings, 53 delivery and 29 launcher tests successfully.
Existing blockers remain reproducible: src/pool_router.py:53-58 permits two successful claims after the find/create/claim/recreate interleaving; :91-93 gives fan-out recipients one global completion identity; src/pool_route_handler.py:61-63 returns DECLINE=3 for an explicitly addressed task with missing roster, which the watcher sends to core. Valid-roster control returns 0 and the .txt payload is readable. Use shared publication/claim arbitration, per-recipient completion identity with payload retention, and non-fallback refusal for unreadable routing state.
The earlier source-key blocker is withdrawn under #4037's raw-channel-key decision; the old prefixed fixtures/docs need consistency cleanup. This stack also inherits #4108's newly verified reversal of the hold-marker fix (parent suite: 44 passes on parent, six failures on child), plus the unresolved runtime-launch and parent storage/recovery blockers. Worst case remains duplicate effects, lost replies or work sent to the wrong recipient.
Reviewed current child diff, inherited interaction and existing findings. Child added-path scan passes; scratch merge with the current parent is conflict-free. Merge order is #4106 → #4107 → #4109 → #4108 → #4110; rerun after parent synchronization. No contributor branch or live runtime changes. COMMENTED because this login authored the PR.
Reviewed by Qingyun's Codex automation.
0628b36 to
ba9bedd
Compare
f2f53c8 to
ae6358f
Compare
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at ae6358fd. COMMENTED because the authenticated account authored this PR.
The .claimed → .accepted rename updates the router's terminology and test calls, but leaves the existing three blockers intact:
- [P1] Duplicate acceptance (
src/pool_router.py:53-58). A misses both names; B publishes and accepts; A recreates pending; a second productionpd.accept()overwrites the accepted name. Fresh probe:two successful accepts True. Serialize publication/acceptance/release across both names. - [P1] Fan-out shares completion identity (
:91-93). Each member still uses the same global payload/result path. The documented filesystem sequence lets A archive B's unread payload; if both read first, two result publications retain only one answer. Per-recipient result-id control retains two. Define member completion identity and shared-payload retention. - [P1] Unreadable routing state falls through to core (
src/pool_route_handler.py:61-63). An explicitly addressed task returns probe=3 without the roster, versus 0 with it.watch-tasks-stream.sh::dispatch_taskemits to core for 3. Use a non-fallback refusal, including failures after a successful probe.
20 router, 11 handler, 24 bindings, 53 delivery and 29 launcher tests pass; current-parent scratch merge is conflict-free. Worst case remains duplicated execution, lost replies or wrong-recipient work. These repairs and inherited parent blockers, including the hold-classifier reversal, remain required for approval.
Current diff, relevant surrounding code and existing threads reviewed. Added-path scan passes; an injected forbidden-path control fails. CI/CLA freshness checked: CLA green, hosted checks still running. No live runtime or contributor branch changed. Stack order: #4106 → #4107 → #4109 → #4108 → #4110; recheck after parent synchronization.
Reviewed by Qingyun's Codex automation.
ba9bedd to
e9b81ff
Compare
ae6358f to
21724ff
Compare
Live measurement: emit latency under the handler, and one thing it is NOTNine hours of live routing on a real core, with the handler installed on the production watcher. Recording what only a live run shows. Routing itself is clean: 18 tasks routed to a worker, 0 reaching the core, 0 mis-delivered, and the core’s own tasks continue to arrive on the decline branch exactly as before. But the handler adds latency to the core’s own intake, and it is not small. One task ( Why this matters for review: I am NOT claiming the cause is proven. What I can state from disk: no task was dropped; the watcher and its fswatch child stayed alive throughout ( Suggested for a reviewer to check, since I cannot from inside: whether the probe belongs off the read-loop thread, and whether a task whose probe accepts should be emitted-with-a-marker rather than silently withheld, so a slow handler degrades into duplicate visibility rather than apparent silence. Also worth a reviewer’s eye: |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 22ef1b6d. COMMENTED because the authenticated account authored this PR.
The payload guard is verified: 25 router tests pass; removing only the guard yields three failures. Missing/archived payloads now enter skipped, not already. The inherited legacy-sentinel repair also passes (54 delivery tests); 11 handler tests pass.
The existing blockers still reproduce:
- [P1] Duplicate acceptance (
src/pool_router.py:57-64): with a real payload present, A misses both names; B publishes and accepts; A recreates pending; a second productionpd.acceptsucceeds on the same accepted path. Serialize publication/acceptance/release across names. - [P1] Fan-out loses member work/results (
src/pool_router.py:99-103): route to A+B, let A archive the shared payload, then B's production reader returnsNoneand sweep deletes B's sentinel as stale. If both read first, the documented two result publications retain one answer; distinct-id control retains two. Define member completion identity and payload retention. The new existence guard does not solve this. - [P1] Unreadable roster falls through to core (
src/pool_route_handler.py:61-63): explicitly addressed task yields probe=3 without the roster, versus 0 with it; watcherdispatch_taskemits to core for 3. Preserve a non-fallback disposition, including failure after the probe.
On the latency report: the probe runs synchronously, but the real handler is spawned asynchronously by drain_dispatch_queue. I have not established the cause of the reported 2.5-minute delay. Claim acquisition failure can mean another live watcher already owns the task, so the success return alone does not prove loss.
Worst case remains duplicate execution, lost answers or wrong-recipient work. The listed repairs and parent blockers are required for approval. Hosted coverage also currently fails under instrumentation; see its job log.
Reviewed the current diff, surrounding code and existing threads. Added-path scan passes; an injected forbidden-path control fails. Current-parent scratch merge is conflict-free. CLA was green; hosted coverage failed and typecheck was still running at inspection. Stack order: #4106 → #4107 → #4109 → #4108 → #4110; recheck after parent updates. No contributor branch or live runtime changes.
Reviewed by Qingyun’s Codex automation.
|
@qingyun-wu — reviewed the child-only layer of this PR ( One finding, and it is a naming hazard rather than a logic bug. Both callers resolve the binding key with the same expression: # pool_router.route and pool_route_handler.classify
source = task.get("channel_id") or task.get("source") or ""
targets = pr.targets_for(r, source, task.get("requested_worker"))So the key is the channel id, with the task's Measured against Rows 1 and 2 differ, so the probe discriminates; rows 3 and 4 fire on the known positive, so a A Two ways to close it, both cheap, your call which:
Not blocking, and I have filed no formal review here. One correction I owe on my own account. I raised this on #4037 an hour ago as a possible key mismatch between Stand: Echo Act IV Pro |
|
Correcting my comment above (5617768529). @qingyun-wu's COMMENTED review 5164524859 at this same head I wrote that " if pd.find(workspace, recipient, task_id) is not None: # checks BOTH names
return "already"
...
os.close(os.open(d / (task_id + pd.PENDING_SUFFIX), os.O_CREAT | os.O_EXCL))
Their second [P1] on fan-out at How I got it wrong, since the process failure is the more useful half. So: strike the clean-on-the-usual-things paragraph from my review. What I still stand behind is the separate finding in that comment — the Stand: Echo Act IV Pro |
e9b81ff to
02792a6
Compare
…k the worker
`read_task` filled a missing `channel_id`/`source` from
`parse_task_headers_lenient()`, whose own contract warns that an absent key
can then come from the body. `task:` is the last header, so everything below
it is untrusted text a sender controls — and it was selecting the recipient.
Built with the canonical task-last writer (`serialize_task_last`), a task with
`source: health-check`, no channel header and `channel_id: bound-room` in the
body, against a roster binding `bound-room` to a worker:
parent 6c6834d: canonical strict channel=None
handler channel='bound-room' probe=0 run=0 worker_got_it=True
this commit: canonical strict channel=None
handler channel=None probe=3 run=3 worker_got_it=False
Every field the router reads — id, channel_id, source, access_tier,
requested_worker — now comes from the strict parser, and a task whose header
names no channel routes to the core with the reason in the log.
Consequence, deliberately taken: a task-mid file (the gateway writes `task:`
before `source`/`channel_id`, see _TASK_FIELDS in the vendored
remote_gateway_bridge) now routes to the core. The writer is what converges;
the router does not guess from prose. `test_the_gateways_field_order_still_routes`
is rewritten to pin that.
Reported by @keweichen (P1 #1) and seconded by @john-the-dev, who found the
same class on #4120.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t used
`classify()` loaded the roster and `_deliver()` reloaded it through
`route(..., None)`, so a compile landing between the two made one run decide
twice. The run reports the first decision and performs the second.
Scheduling the roster writer between the two loads, with a task bound to a
worker in v1 and unbound in v2:
parent 6c6834d: atomic v1-bound -> v2-unbound: rc=0 worker=False core=True
this commit: atomic v1-bound -> v2-unbound: rc=0 worker=True core=False loads=1
The handler exits 0 either way, so the watcher suppresses the ordinary core
event — at the parent the task was written into an inbox the reported decision
does not describe.
`main()` loads once and hands the same object to `classify` and `_deliver`;
the run line now carries `roster=v<N>`, so the version a decision was made
against is recoverable afterwards. `route(roster=None)` keeps loading for its
other callers. The load count is pinned at 1, not just the outcome.
Reported by @keweichen (P1 #2) and confirmed by @john-the-dev.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k recovers
`_defer()` wrote `state/pool-route-retry/<id>` and exited 0; `retry_pass()` and
`--retry-pass` had no production caller. Repairing the fault touches only
`state/`, which the tasks-only watcher never sees, so nothing re-delivered
without a restart or a hand-run CLI — and the Stop hook hides a marked task
from the core, so the work sat held indefinitely.
Every real handler run now runs the pass before its own task: the next task
event IS the driver, which is the one event the watcher is guaranteed to get.
A fan-out binding defers task-defer, the binding is then repaired in state/,
and an unrelated task-next arrives:
parent 6c6834d: refused run: rc=0 marker=True delivered=False
next task event: marker=True delivered=False
this commit: refused run: rc=0 marker=True delivered=False
next task event: marker=False delivered=True
Bounded so the task that woke the run is still handled: oldest marker first,
at most `--retry-limit` per run (default 20), the remainder counted as
`held_over` for the next event. Each marker's outcome is logged, a vanished
payload drops its marker, and a crash inside the pass cannot fail the routing
of the task that triggered it. `--retry-pass` still runs the same pass by hand.
Reported by @keweichen (P1 #3).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…held
`src/pool_delivery.py` deliberately recognises the pre-rename `.claimed`
acceptance during the pool rollout; `src/check-pending-tasks.sh` spelled its
own suffix set and knew only `.txt` and `.accepted`. A task a worker had
accepted under the legacy name therefore read as UNPROCESSED, and the hook
told the core to answer work already in flight.
Two spellings of one grammar is the defect, so the fix is one owner rather
than a third copy: `pool_delivery.held_by_worker()` plus a `--held <id>` CLI
(exit 0/1, prints the holder), which the hook now calls.
parent 6c6834d: FAIL a worker's legacy .claimed exempts
wanted allow, got block
FAIL pool_delivery --held reports the legacy hold
exit was non-zero
this commit: ok a worker's legacy .claimed exempts
ok pool_delivery --held reports the legacy hold
`tests/check-pending-tasks-worker-held.test.sh` pins core/worker across
pending/.accepted/.claimed through the real hook. The two controls carry as
much weight as the case: an undelivered task must still block, and the CORE's
own inbox must never be a hold — the core declining is what puts a task in
front of this hook. `TestHeldByWorker` pins the same grammar in-process, where
the coverage gate can see it (a shell test drives a subprocess and scores zero).
Without a resolver-supplied interpreter the hook cannot ask, and cannot emit
a block either (it already degrades to `{}`), so the exemption is unreachable
rather than wrong.
Reported by @keweichen (P1 #4).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… recovers dispatch_task probes first and queues a real run only on exit 0/4. An unbound task classifies 3, and the real run was the only caller of retry_pass — so on a host receiving only core-bound traffic, every deferred delivery stayed parked. The probe is the watcher's read-only question and must stay one, so the driver cannot move there. The decline branch now runs the same pass explicitly over `--retry-pass`, whose output and exit code are discarded: a handler that does not know the verb costs the declined task nothing. A deferral whose marker cannot be written loses the only thing retry_pass enumerates. The exit code cannot say so (any non-zero hands the task to the core), so it is said on stderr, which the watcher's own log keeps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Stop hook read any non-zero exit from `pool_delivery.py --held` as "no worker holds this", and `2>&1 >/dev/null` removed the only channel that told a traceback apart from a clean negative. A broken lookup therefore reported a worker's in-flight task as unprocessed and told the core to answer it. --held now exits 2 when it cannot determine the answer, with the exception on stderr. The hook branches on the code: 0 exempt, 1 report, anything else UNKNOWN — not reported, and named on stderr rather than dropped silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ds holds it `_defer` wrote ONE marker store. With `state/pool-route-retry` obstructed the run still exited 0, so the watcher released its claim, `retry_pass` — which enumerates markers — never saw the task, and the Stop hook then reported a worker-addressed payload to the live core as unprocessed work. A transient state-write failure could therefore re-expose a worker's task to the core, and a later restart could deliver it to the worker too. The marker now has a second store under `tasks/.pool-route-retry`, which shares no component with the first below the workspace and is hidden from every `tasks/*.txt` glob. `retry_pass` enumerates both, so one obstructed path no longer takes automatic retry offline — recovery needs no repair and no restart. When NO store takes the marker the run exits UNSETTLED (5) instead of 0: 0 would settle work no pass can find, and any other non-zero takes the watcher's fallback disposition and hands a worker's task to the core. `finish_handler_task` maps that one code to "keep the claim, publish nothing". The Stop hook stops spelling the marker path and asks the handler (`--parked`, 0/1/2 like `--held`). A store obstructed by a file is a NEGATIVE — nothing can be stored under it — so only a genuinely unreadable store is UNKNOWN, and the hook does not go blind for every task when one path breaks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… names it spawn_worker points the worker's watcher at `deliveries/<worker>`; the router writes a ZERO-BYTE sentinel there; the watcher emitted that basename and handed that path to the handler. `pool_delivery.read_payload` could resolve the body, but no launcher or watcher path called it — so a launched worker was handed 0 bytes where its task was (probe: payload_bytes=61, watched_file_bytes=0). The resolution belongs in the watcher, at the one place an inbox event becomes a task path, and the KIND of inbox is injected by the adapter that named it (`SUTANDO_INBOX_KIND`, set by spawn_worker). The watcher never infers "this looks like a delivery folder" from a path, and the sentinel->payload grammar stays pool_delivery's: a new `payload` verb answers it for callers outside python. Putting the mapping in the launchers instead would copy that grammar into every runtime, which is the duplication the boundary rules forbid. The Codex path additionally lost the shared workspace: the spawner set SUTANDO_WORKSPACE_DIR but no SUTANDO_RESULTS_DIR, the launcher forwarded neither to the notifier, and task-notifier derived results, state, claims and logs from the inbox's parent — `deliveries/results`, which no bridge drains (matches=False). Both launchers now forward the workspace/results/inbox-kind seams, and the notifier resolves every one of those paths from the workspace. spawn_worker composes neither folder itself any more: `pool_delivery` owns the deliveries and results directories, which is also what the state-paths adoption guard asks for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the redirect is recorded @yixuan-ag2's nit at pool_router.py:15 — the last sentence of the module docstring was a fragment, so the rule it states ("re-aiming work at a reachable worker is how a task reaches someone the owner never addressed") read as nonsense. The docstring now also says where an unknown target is surfaced, which is the fact that turned that reviewer's "silent mis-route" reading around. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bprocess The `payload` verb and the handler's usage error were exercised only through `subprocess.run`, which scores zero coverage on the code it ran — so ~15 added lines read as untested to the diff-coverage gate while being covered in fact. The same answers are now asserted in-process as well, alongside the two branches nothing reached at all: a marker store that cannot be enumerated, and a run given neither a task file nor a verb. Local, over the four suites that touch the changed modules: pool_delivery 91% -> 98%, pool_route_handler 95% -> 96%; every remaining miss is a pre-existing line outside this PR's diff. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…omment cap The third line restated what the two above it already said; every other section marker in this file is exactly two lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nsitively calls The harness eval'd a single awk range for finish_handler_task with stderr discarded, so PR #4165's refactor (finish_handler_task -> settle_handler_outcome) would silently leave the lift stale: eval hits "command not found", the watcher prints nothing, and the case above — which asserts absence of output — passed vacuously while the real assertion failed for the wrong reason. - Walk the call graph from finish_handler_task (excluding stubbed collaborators) so the lift stays correct whether or not settle_handler_outcome exists. - Capture eval's stderr instead of discarding it; fail if it contains "command not found". - Require a positive marker line from the UNSETTLED branch so an undefined-command failure can no longer pass the absence-asserting case. Verified: 11/11 passes at this head, and 11/11 against a scratch merge of origin/fix/reaper-reads-runner-rc (settle_handler_outcome present), which was then aborted — no source change is committed here.
The router, its handler and their suites are pool-only: a single-core install (N=0 workers) never loads them. So they follow pool_roster into the skill, and every core caller reaches the handler through the env seam the install names. The Stop hook asked `src/pool_route_handler.py --parked` by path. It now asks $SUTANDO_TASK_EVENT_HANDLER — the same handler the watcher probes and runs, and start-cli forwards. Unnamed, the task is REPORTED rather than exempted: no router ran, so nothing could have parked it. Named but unable to answer, its exit 2 still means "not reported, said on stderr" — unknown is not a negative. Staying core: pool_delivery (delivery-record grammar, incl. the `payload` subcommand the watcher resolves a sentinel with), the UNSETTLED rc-5 branch in finish_handler_task, the --retry-pass call on a decline, and --held. Those are protocol numbers and verbs; none names a file here. CI's shell-suite discovery gains the `skills` root the Python step already has, so the three moved .test.sh files keep running. Behaviour, tests and assertions are unchanged — only paths, imports and seams.
611dbf4 to
43a74e2
Compare
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 43a74e2d3a9fe5b3510d328d0cb8fa40b9d4d558. This is a COMMENT review because the authenticated account is also the PR author.
The move into the optional skill preserves router/handler behavior apart from import bootstrap and the new injected --parked hook. I inspected the full 19-file child diff against parent #4108 at 99c058d66, the cumulative interaction, moved-test deltas, and prior threads. The new hook adds one blocking unavailable-handler case, attached inline.
Production hook with the same parked payload and durable retry marker throughout:
named executable handler: {}
named non-executable handler: decision=block; stderr empty
named missing handler: decision=block; stderr empty
Both failures tell core to process the parked worker task. A configured handler that cannot answer must take the unknown/error path; only an unset handler may take the optional-skill-absent default.
The older failed-deferral blocker also persists at the new paths. Actual production claim/finish/retry/shutdown functions, with event emitters captured and signals stubbed:
both retry stores obstructed: handler_rc=5
claim_after_finish=held
Stop hook: decision=block, includes worker-owned task
storage repaired + valid roster: retry lists empty; delivery_after_repair=no
shutdown: SHUTDOWN_CORE_EVENT task-unsettled.txt
claim_after_shutdown=released
Holding the claim alone neither makes the work discoverable by --retry-pass nor protects it from the Stop hook or shutdown fallback. Preserve recoverable worker ownership across those consumers and test the complete failure/repair/shutdown sequence; the existing inline thread remains unresolved.
Focused suites pass: handler49, router25, roster29, delivery74; retry-driver7, marker-recovery11, inbox-composition11, parked-seam7, held9, held-unknown4, workspace7, env-forwarding3. Child and cumulative added-path/root/prose scans pass; forbidden-path control rejects. Current CI and CLA are green. Parent-first merge order remains #4188 → #4108 → #4110; the unchanged parent bootstrap findings and missing real post-restart round trip remain outstanding.
Worst-case disruption is core executing work assigned to a worker or leaving it stranded after a transient storage failure. Fix the two reproduced ownership paths and supply the live witness before approval. No contributor branch or live service was changed.
| # A task parked for a retry pass is a worker's too. The handler the pool's | ||
| # install named answers; core holds no path to it, so a skill can move. | ||
| PARKED_RC=1 | ||
| if [ -n "${SUTANDO_TASK_EVENT_HANDLER:-}" ] && [ -x "${SUTANDO_TASK_EVENT_HANDLER}" ]; then |
There was a problem hiding this comment.
[P2] Treat a configured unavailable handler as unknown. The new -x guard skips the parked query and leaves PARKED_RC=1 when the configured script is missing or loses execute permission. With an existing state/pool-route-retry/ and worker payload, the actual hook returns {} while the script is executable, but emits a block telling core to process the identical task after chmod 644 or removal; both failures are silent. Distinguish an unset optional handler from a named handler that cannot answer, and cover missing/non-executable paths alongside exit 2.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head 43a74e2d3a9fe5b3510d328d0cb8fa40b9d4d558.
I reviewed the 19-file child layer against parent 99c058d664837a453222eda485c862f6c8d53dab and the cumulative interaction from the origin/main merge-base 436dcc239bda3c09f47f93f96417b49c19f57068. The parent has the delivery reader/spawner but no router/handler, so the need is confirmed. Four implementation blockers remain.
[P1] A spawned worker inherits the core router and suppresses its own assignment
skills/worker-pool/scripts/spawn_worker.py:177 launches with the complete ambient environment. The new forwarding at src/agent/claude/cli/start-cli.sh:203 propagates SUTANDO_TASK_EVENT_HANDLER without excluding WORKER_INSTANCE. The worker therefore starts its delivery-inbox watcher with the core router installed.
When that watcher receives an already-routed sentinel, dispatch_task probes the real router, queues it, and pool_router.deliver_one() returns already at skills/worker-pool/scripts/pool_router.py:65-67. The handler exits 0, so no task event reaches the worker.
Production-function harness, with paths normalized:
HANDLER_ABSENT WORKER_EVENT task-worker.txt
HANDLER_INHERITED HANDLER_QUEUE <workspace>/tasks/task-worker.txt fallback
HANDLER_RUN_RC 0
The inherited case left the sentinel present and emitted no worker event. The existing composition test forces DISPATCH_DIR empty for this control, so it does not exercise the inherited real handler.
Unblock by scoping the router to the canonical core or explicitly clearing it from worker sessions, then test configured-core spawn → worker launcher → real delivery dispatch with the real handler and assert that the worker receives the canonical payload.
[P1] UNSETTLED has no durable ownership across the Stop hook or watcher shutdown
If both retry stores reject a marker, _defer() returns 5 at skills/worker-pool/scripts/pool_route_handler.py:248-275. src/watch-tasks-stream.sh:263-266 keeps the current claim, but its own comment correctly says ownership is recorded nowhere else. The Stop hook does not inspect claims; it asks --parked, which returns 1 because no marker exists. Graceful cleanup later treats the fallback-disposition claim as interrupted and emits it to the core at src/watch-tasks-stream.sh:569-593.
ONE_STORE run=0 parked=0 retry.delivered=['task-control']
ZERO_STORE run=5 claim_present=True parked=1 stop_hook=block stderr=<empty>
after repair: retry.delivered=[]; direct rerun delivered=True
Thus rc 5 protects only the lifetime of one watcher process. The same worker-addressed task can be exposed to the core by the Stop path or shutdown, or remain stranded until restart.
Unblock by making unsettled ownership durable and discoverable by retry, Stop, cleanup, and startup/reaping paths. Pin marker failure through Stop plus graceful shutdown/restart, repair storage, and prove automatic retry reaches the worker without core exposure.
[P1] Retry bypasses fresh classification and can report delivery into an unread core inbox
A fresh task whose binding disappeared or names an unknown worker declines to the core at pool_route_handler.py:107-112. A marked task bypasses that classification: retry_pass() calls _deliver() directly at pool_route_handler.py:334, while pool_router.py:96-100 converts the target to core and writes deliveries/core/<id>.txt.
initial_defer_rc=0
fresh_probe_rc=3
retry.delivered=['task-core']
marker_left=False
core_sentinel=True
No production invocation reads deliveries/core: the production-only git grep returned rc 1, while the same probe over tests returned rc 0. The retry therefore clears its only marker and says delivered even though the ordinary core watcher never receives that event.
Unblock by applying the same disposition to retries as fresh dispatch and sending core-directed retries through the core’s actual task/event channel, with a deferred → binding removed/unknown regression.
[P2] A configured but unavailable handler is treated as a clean negative
At src/check-pending-tasks.sh:57, the handler is called only when it is executable; otherwise PARKED_RC remains 1. An unset optional capability may legitimately mean not parked, but a nonempty path that disappeared or lost execute permission means the answer is unknown.
EXECUTABLE allow, stderr=<empty>
NON_EXECUTABLE block, stderr=<empty>
MISSING block, stderr=<empty>
That silently tells the core to process a task which the configured router may have parked. The re-home itself makes stale old paths a realistic trigger. Distinguish unset from configured-unavailable, fail closed with a diagnostic, and cover missing/non-executable paths; audit the identical availability assumption in the watcher/notifier callers too.
Worst case across these paths is a worker-bound task disappearing from the worker-visible stream, then being processed by the core and later retried to the worker. For email, deletion, or financial work that becomes a wrong-actor or duplicate irreversible action. The mitigations are core-only handler scope, durable lifecycle-wide ownership, one classification contract for fresh and retried work, and tri-state handler availability.
The required live-path witness is also still absent. The supplied PR body explicitly says no post-restart round trip was run, and this isolated review was prohibited from starting or restarting services. A fixing head needs a real post-restart bound-task → worker → canonical-result round trip, with an unbound control proving the core still receives its own task.
Focused local checks were green: handler 49 tests, router 25, delivery 74; the marker-recovery, retry-driver, worker-inbox, three pending-task hook, and launcher-env shell suites all passed. Both child and cumulative git diff --check were clean. Both review-check runs reported:
prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
A synthetic /Users/reviewer/private mutation was rejected, so the host-path control fires; no added host-specific paths were found. bash -n and py_compile were clean. ruff was unavailable locally (command not found); the supplied metadata reports the hosted check green, which I did not independently query. The worktree remained clean and HEAD was reconfirmed after validation.
Reviewed by Kewei's Codex automation.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
|
Closed at the owner's direction (PR triage, 2026-09-12 03:45Z: "close the ones that are dated"). Superseded: #4206 landed the module move, and the tested multi-worker behavior is being consolidated into one PR on top of |
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
…as a skill script Re-homed onto the skills/worker-pool layout in one commit, same content as the seven it replaces (ea97038..9053d1a) at the new paths: - pool_advertise.py: report() is the source (worker facts with state verbatim, runtime, roster_version, applied labels and bindings); snapshot() and profile_workers() project today's broker bodies from it; a recovering worker is never rendered as available; write_advertisement() persists the file beside the roster whole-or-not; ensure_advertisement() writes only when the roster has moved past the file, so calling it every run is cheap. - create_worker.py carries spawn()'s runtime onto the roster row and writes the advertisement after the compile, with its own failure text. - The src/startup.sh boot call is gone: the core must not name a pool file by path. The pool's own handler calls ensure_advertisement() on its first run (that call lands with the handler, on the #4110 line). - Tests move with the module: 31 for the advertiser (the boot-call test replaced by one that a second ensure with nothing changed writes nothing), the three create-worker tests folded into the skill's suite.
What this is
5/5 — the last piece of the worker-pool series (#4105). The router pass: resolve one admitted task against the roster, write the deliveries.
Merge order
Base is
feat/pool-bindings-roster;feat/pool-delivery-readeris merged in. It consumes both.A role, not a process
This is a function the core's task watcher calls. There is no daemon here and there must not be one — a second process would re-watch
tasks/, which the watcher already does across code hardened by many incidents.Its whole input is the roster and one task. A test proves it by deleting the roster file and routing from an in-memory copy: no clock, no directory listing, no liveness probe inside the decision. That is what makes a pass replayable.
The rule it may never break: substitution
A test asserts an unknown target creates no
deliveries/core/at all. Re-aiming work at a reachable recipient is how a task reaches someone the owner never addressed.Double delivery, enforced from the writing side
deliver_onechecks both sentinel names:Checking only the unclaimed name recreates a sentinel for work already in flight and delivers it twice — the defect fixed in 1/5, now enforced where deliveries are written too.
EEXISTis success: another pass won the race, and the delivery exists either way.Two smaller decisions
One pass reads the roster once, so every task in it is decided against the same
versionand a recompile mid-pass cannot split the decision.Ordering is
urgent > normal > low, then oldestcreated_at. A task with no priority sorts as normal, not last — an absent field is a default, not a demotion.Evidence
The last uncovered branch was the real race — a sentinel appearing between the check and the
open, whereO_EXCLis what actually arbitrates. Covered by making the pre-check miss it, rather than by pre-creating the file, which the earlier check catches.scripts/review-checks.shPASS — it flagged a three-line comment block first, and the fix keeps# noqa: E402inline where the directives still apply rather than collapsing them into one line that would silence nothing.Composition
All five modules were exercised together against a scratch workspace: spawn a worker, bind a room, compile the roster, route, claim, complete. Payload never copied (sentinel 0 bytes, payload intact), the second worker saw an empty sweep, and completion retired the spent sentinel instead of handing it back.
Refs #4105, #4037
Payload guard (2026-09-10,
22ef1b6d0).deliver_onenow returns"no-payload"and writes nothing whentasks/<task-id>.txtis absent, androute()collects those into askippedlist rather than folding them intoalready— a refusal must not read as a success. Motivating case, found the destructive way: a hand-call created a sentinel for a task whose payload had already been archived, which is finished work offered a second time. Contributed by the live pool worker; controls confirm 3 tests go red with the guard removed and a 4th with the refusal folded back intoalready.Deployment order
Strict-header-only routing (
89f649a5b) makes this router and the gatewaywriter a coupled pair, so the order matters.
The gateway bridge still writes
task:beforesource/channel_id—_TASK_FIELDSinpackages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1719reads
"task", "source", "channel_id", and the live task files on the poolhost have that shape:
The router no longer recovers routing metadata from
anything below
task:, so until the writer converges to task-last everygateway task routes to the core.
That is the safe direction — the core is a real recipient, not a dropped task —
but it is not the intended one, so:
Deploy this router together with, or after, the writer change. #4121 moves
source/channel_idabovetask:in_TASK_FIELDS. Rolling this branch outfirst degrades the pool to core-only for gateway traffic; rolling #4121 out
first is harmless with either router.
Review fixes (2026-09-11,
64916421a)Three threads on
65f30697d. Every tail below is the actual command output atthe parent commit and at
64916421a, run in a detached worktree.1. The retry driver was unreachable on the unbound-event path
@keweichen [P2] at
src/pool_route_handler.py:128.dispatch_taskprobesfirst and queues a real run only on exit 0/4 (
src/watch-tasks-stream.sh393-416). An unbound task classifies 3, and the real run was the only
caller of
retry_pass— so on a host whose traffic is all core-bound, markedwork stayed parked indefinitely.
The probe is the watcher's read-only question (
test_the_probe_runs_no_retry_passpins that), so the driver cannot move there. The decline branch now runs the
same pass explicitly over
--retry-pass; its stdout and exit code arediscarded, so a handler that does not know the verb costs the declined task
nothing. The extra process only ever runs when a handler is configured —
dispatch_taskreturns early whenDISPATCH_DIRis empty.New test lifts
dispatch_taskverbatim out of the production script withawkand runs it against the real handler, stubbing only the emit/queue
collaborators — the same shape
tests/watch-tasks-dispatch-activity.test.shalready uses. Two-sided:
The last four lines are the controls that separate the fix from the bug: a
watcher that ran the pass unconditionally would break the bound branch, and a
probe that delivered would pass case 1 for the wrong reason.
2. An unwritable retry marker no longer loses recovery silently
@keweichen [P2] at
~161-170._deferloggeddeferred WITHOUT markerandreturned 0. The marker is the only thing
retry_passenumerates, so the taskwas recoverable only by the watcher's next startup sweep — and nothing said
so at the time.
The exit code cannot carry this: any non-zero from the real run takes the
fallbackdisposition infinish_handler_taskand hands the task to the livecore, which is the one outcome the owner rule forbids. So it is announced on
stderr, which the handler-runner leaves inherited (only stdout is
>/dev/null) and the watcher's own log keeps.The assertion is on the literal string, not on
h.UNMARKED_NOTICE, so theparent fails on a silent stderr rather than on a name that does not exist
there yet. A third test is the control: a deferral that keeps its marker prints
nothing, so the notice names a real loss and not every deferral.
What this does not do: it does not add a second durable marker store. A
task deferred with no marker is still recovered by the watcher's initial sweep
(
for f in "$TASKS_DIR"/*.txt; do dispatch_task "$f") and not before it; thenotice says exactly that rather than implying
--retry-passcould find it.3.
--heldcan now say "I could not determine that"@john-the-dev's note. The hook read any non-zero exit as not held, and
2>&1 >/dev/nullremoved the only channel separating a traceback from a cleannegative — so a broken lookup reported a worker's in-flight task as unprocessed
and told the core to answer it.
pool_delivery.py --heldnow exits 2 when the lookup raises, with theexception on stderr. The hook branches on
$?: 0 exempt, 1 report, anythingelse UNKNOWN — not reported to the core, and named on stderr rather than
dropped silently. No interpreter still reads as 1, so the separate
no usable interpreter; queue not reportedrefusal below is unchanged.The unreadable tree is produced with
chmod 000and the case skips itself ifthe mode does not take (root). The first line is the control: a hook that
exempted everything would also stop reporting this task.
tests/pool-delivery.test.pygains the same answer twice — once withheld_by_workerpatched to raise, once through a real subprocess so the hook's$?branch is pinned against an exit code and not a patched function:Cross-PR collision with #4167 — deliberately not resolved here
@john-the-dev flagged it and I agree with the ordering. #4167 rewrites the
same block of
src/check-pending-tasks.sh, has two approvals, and lands firstper the repo owner's "core-only seam changes first". Resolving it in this
branch now would only have to be undone.
#4110 will rebase onto
mainafter #4167 merges and rework that hunk, withthe
--helddelegation plus the UNKNOWN branch above carried onto #4167'sversion of the loop and the full check set rerun on the result. Until then the
hunk here is expected to conflict, and that conflict is not a review finding.
Full suite + gate at
64916421aprose_extsis['.py'], so the scanner reads no shell. Hand-counted: everyadded in-body shell comment block is 2 lines or fewer. The two new shell test
files carry the usual defect/control/isolation file header, matching every
sibling shell test on this branch and on
main.Review fixes (2026-09-11,
6b632f09d)@keweichen's two blockers at
64916421a, plus @yixuan-ag2's open nit. Everytail below is the actual command output at the parent commit and at the new
head, run in a detached worktree; no live service was started or signalled.
(
6b632f09dadds one test-only commit on top of1a04349c6: the new CLIbranches were exercised only across a
subprocess, which scores zero coverage,so the same answers are asserted in-process too. It was force-pushed once, within
a minute, to undo an accidental
.coveragercdeletion indd0428f29— that SHAexisted on the branch for about 60s and no review points at it.)
1. A failed retry-marker write no longer settles unrecoverable work
The ask.
_deferreturned 0 after failing to create the only markerretry_pass()enumerates, so the watcher released its claim atsrc/watch-tasks-stream.sh:277-279, the pass never saw the task, and the Stophook then reported a worker-addressed payload to the core
(
handler rc=0; marker=False; delivery=False->decision=block). Do notreport settled unless durable, non-fallback ownership or an automatically
discoverable recovery path exists.
The mechanism, both halves of kewei's "and/or".
retry_dirs()returnsstate/pool-route-retry/thentasks/.pool-route-retry/. The second sharesno path component with the first below the workspace, so one obstructed
directory cannot take retry offline; it is hidden, so the
tasks/*.txtglobevery producer and consumer uses never sees it.
retry_passreads both, whichmakes the marker discoverable before any repair, not only after one.
finish_handler_tasksupports first: it branches on the claim's disposition(must-handle / fallback / unrecognised), not on rc, and every non-zero rc ends
in one of those branches —
fallbackpublishes a worker's task to the livecore, which the owner rule forbids. So the handler returns
UNSETTLED(5) andthe watcher maps that ONE code to "keep the claim, publish nothing": the task
is neither settled nor handed over, and the next watcher start re-dispatches it.
The Stop hook stops spelling the marker path. It now asks the owner —
pool_route_handler.py --parked <id>, 0/1/2 exactly like--held. Onerefinement over the
--heldshape: a store obstructed by a file is anegative, not an unknown (nothing can be stored under a file), so the hook
keeps working on every other task while one path is broken; only a store that
genuinely cannot be read is UNKNOWN and therefore not reported.
Regression test, exactly the sequence kewei asked for — marker-write failure ->
storage repaired -> automatic retry delivers, and the hook never names the
parked task. Two-sided:
Three of those lines are the controls that separate the fix from the bug: an
unparked task in the same workspace is still reported (a hook that exempted
everything would also stop reporting the parked one); an ordinary handler
failure still falls back to the core (so the new code did not simply disable the
fallback); and the last two
okat the parent are the pre-existing behaviour thechange must not have needed.
finish_handler_taskis lifted verbatim out of the production watcher withawkand run against stubbed claim/emit collaborators that record which branchran — the same shape
tests/pool-route-retry-driver-dispatch.test.shuses.2. A launched worker now receives the task, not the sentinel
The ask.
spawn_workerpoints the worker's watcher atdeliveries/<worker>,deliver_onewrites a zero-byte sentinel there, and nothing calledpool_delivery.read_payload— so the worker was handed 0 bytes(
payload_bytes=61; watched_file_bytes=0). The Codex path also lost theworkspace: no
SUTANDO_RESULTS_DIR, no forwarding ofSUTANDO_WORKSPACE_DIR,and
task-notifier.shderived results from the inbox's parent(
deliveries/resultsvs canonicalresults,matches=False).Where the fix lives, and why. In
watch-tasks-stream.sh'sdispatch_task— the one place an inbox event becomes a task path — with the kind of inbox
injected by the adapter that named it (
SUTANDO_INBOX_KIND, set byspawn_worker.plan()). Two rules decide this:of an optional capability belongs at the adapter edge (CLAUDE.md, "Optional
capability discovery stays at the adapter edge"). It is told, it never sniffs
the path.
launchers instead would copy that grammar into the Claude launcher, the Codex
launcher and every future one — the duplicated policy CLAUDE.md calls a defect
in its own right.
pool_deliverygrows apayloadverb (built on the samepayload_path()thatread_payload()uses) and both adapters ask it.The emitted line still carries the bare task id, so a worker's core reads
<workspace>/tasks/<id>.txtexactly as the core does; only the resolutionchanged, not the protocol.
Both launchers now forward the whole seam (
SUTANDO_WORKSPACE_DIR,SUTANDO_RESULTS_DIR,SUTANDO_INBOX_KIND), andtask-notifier.shderivesresults, state, claims, fallback receipts, core-status and logs from the
workspace rather than from
dirname "$TASKS_DIR".spawn_workercomposesneither folder itself any more —
pool_deliveryownsdeliveries_dirand a newresults_dir, which is also whattests/state-paths-adoption.test.pyasks for(it fails on a hand-composed
<workspace>/results).Composition probe: routes a real task through the production router, launches
nothing, and runs the watcher's own read path over the delivery folder.
dispatch_task, both results resolutions and the notifier'spayload_filearelifted verbatim from the production scripts, so this pins shipped text rather
than a restatement. Two controls are in the same run: the sentinel's own size is
asserted to be 0 (so "the handler got 68 bytes" cannot be true by accident), and
an ordinary core inbox is asserted unchanged (so the fix cannot be "always
rewrite the path"). The one parent-
okin the middle is honest: the Claude-sidewatcher already resolved results correctly via #4108's
SUTANDO_WORKSPACE_DIR;only the Codex side lost them.
3. @yixuan-ag2's open items
The
unknown_targetscontract disagreement (#4109 vs here). You asked theauthor to pick: the behaviour here is the one I am keeping — an unknown
target goes to the core, and the name it was addressed to is reported in
redirected, which is what makes it a recorded redirect rather than a silentmis-route. The text that changes is #4109's
unknown_targetsdocstring, now onmain; it is a one-line doc change and it does not belong in this layer's diff,so it is a separate PR I have not filed yet rather than something quietly done
here.
The
pool_router.py:15fragment is fixed in1a04349c6, and the docstringnow also names where the redirect is recorded.
redirectedhas no consumer yet — still true, still one hit (:112, theline that constructs it). Unchanged by this push, and noted as a [5/5] property
rather than answered.
Still outstanding, deliberately
watchers on this host run from an older checkout; rolling them forward is the
owner's call at a quiet moment, and this work was done in an isolated
worktree. Everything above is deterministic-harness evidence plus
parent-vs-head probes. The task -> worker -> canonical-result round trip
stays a merge condition.
and parent feat(pool) [4/5]: spawn a worker — identity, folder, tmux session, watcher #4108 lands first.
check-pending-tasks.shhunk will rebase onto fix(watcher): the inbox and the workspace may be named by env; a worker-held task is not unprocessed #4167. fix(watcher): the inbox and the workspace may be named by env; a worker-held task is not unprocessed #4167 rewritesthe same block, is 2-approved and lands first under the owner's "core-only
seam changes first" (@john-the-dev's collision, which I agree with). After it
merges this PR rebases onto
mainand reworks the hunk, carrying the--helddelegation, the UNKNOWN branch and the new
--parkeddelegation onto fix(watcher): the inbox and the workspace may be named by env; a worker-held task is not unprocessed #4167'sversion of the loop — whose grammar is
held_by_other_instance/self_recipient,so the rebased hunk adopts that spelling rather than re-introducing
held_by_worker. The full check set is rerun on the result. Until then theconflict is expected and is not a review finding.
next_pending_tasksortstasks_dir.glob("*.txt")by header priority, and asentinel has no headers). Pre-existing, unrelated to the two blockers, and
invisible until a worker has more than one queued task; naming it rather than
folding a third change into this push.
Full check set at
6b632f09dThe restart-launching suites (
start-cli-restart-inside-core-guard.test.sh,start-cli-restart-verify.test.py) were not run: they drive a real restarton this host.
prose_extsis['.py'], so the scanner reads no shell. Hand-counted on thecumulative diff: every added in-body shell comment block is 2 lines or fewer
(one 3-line block was trimmed before the final push); the two new shell test
files carry the usual defect/shape/control/isolation header, as every sibling
shell test on this branch and on
maindoes.Two of the python suites are only partly two-sided, said plainly: at the parent,
tests/pool-route-handler.test.pyreports 2 behavioural failures and 10errors that are
AttributeErroron names that do not exist there yet(
retry_dirs,parked,UNSETTLED), and the 4 newpool-deliveryCLI testsfail on an absent
payloadverb. Those errors prove the API is new, not thatbehaviour changed — the behavioural evidence is the two shell tests above.
Rebase onto #4108's new head (2026-09-11,
449d2981a)GitHub reported this PR DIRTY after #4108 moved to
45eba4c08. Rebased--onto origin/feat/spawn-worker-launcherfrom the old basea25ec94c7(the#4108 head this branch was last stacked on), in a detached worktree; no live
service was started, signalled or restarted.
What moved DOWN into #4108 — dropped from this layer
#4108 absorbed the whole launcher/notifier/delivery-path seam, so this PR's
copies are gone rather than duplicated:
SUTANDO_INBOX_KIND/SUTANDO_RESULTS_DIRforwardingnotifier_env_args)task-notifier.shworkspace-derived results/claims/receipts/core-status/logs, pluspayload_filepool_delivery.results_dir()and thepayloadverbspawn_worker.plan()composing neither folder itselfpayloadverb's own unit tests (TestPayloadVerbequivalents)Two conflicts, both resolved by taking #4108's version and re-applying only
this layer's delta on top:
src/agent/codex/cli/start-cli.sh— feat(pool) [4/5]: spawn a worker — identity, folder, tmux session, watcher #4108 refactored the inline notifier-envblock into
notifier_env_args, which already forwards everything this layerused to add. Kept feat(pool) [4/5]: spawn a worker — identity, folder, tmux session, watcher #4108's call; this file's diff against the base is now 0
lines, as are
task-notifier.shandspawn_worker.py.src/pool_delivery.py—commandisnargs="?"(so--heldneeds no verb)and its
choiceskeep feat(pool) [4/5]: spawn a worker — identity, folder, tmux session, watcher #4108'spayload.held_by_worker/--held/HELD_UNKNOWNare the only additions left here.What this layer KEEPS
dispatch_tasksentinel → payload resolution, keyed onSUTANDO_INBOX_KINDand asking feat(pool) [4/5]: spawn a worker — identity, folder, tmux session, watcher #4108'spool_delivery payloadverbSUTANDO_TASK_EVENT_HANDLERforwarding (Claude launcher, +3 lines)held_by_worker/--held/HELD_UNKNOWNtasks/.pool-route-retry/,UNSETTLEDrc 5,--parkedThe file set now
Only this layer's own files. Structural checks on the result:
dispatch_taskstill calls #4108'spayloadverb; nothing is defined twice.Checks at
449d2981aIncluding #4108's own two suites, which is what proves the moved-down hunks
still satisfy this layer's contract:
tests/pool-worker-inbox-composition.test.shis the one that matters most here:it lifts
dispatch_task, both results resolutions and the notifier'spayload_fileverbatim out of the production scripts — which after thisrebase are #4108's copies — so its 11 green lines are evidence about the merged
pair, not about text this branch carries.
tests/pool-delivery.test.pydrops from 77 to 73 tests: the 4 subprocesspayload-verb cases and the 5 in-process ones were removed with the verb, and
#4108's
TestPayloadVerbcovers the same answers.TestHeldByWorkeris now thisfile's entire contribution.
The restart-launching suites (
start-cli-restart-inside-core-guard.test.sh,start-cli-restart-verify.test.py) were not run: they drive a real restarton this host.
prose_extsis['.py'], so the gate reads no shell. Hand-counted on thecumulative diff: every added in-body shell comment block is 2 lines or fewer
—
449d2981atrims one 3-line block intests/pool-worker-inbox-composition.test.shwhose third line restated the twoabove it (every other section marker in that file is exactly two). The six
remaining >2-line runs are all file headers directly under the shebang, the
same shape every sibling shell test on this branch and on
maincarries.The #4167 note above still stands:
src/check-pending-tasks.shis unchangedby this rebase and still expects to be reworked onto #4167's version of the loop
once #4167 lands.
Re-homed onto
skills/worker-pool(2026-09-11,43a74e2d3)Owner directive: non-core pool code moves to
skills/worker-pool/. PR A (#4188)moved
pool_roster+worker_identity; #4108 re-homed the spawner and thebootstrap gate at
99c058d66. This rebases onto that head and moves thislayer's own two modules and their suites the same way.
Only paths, imports, globs and seams changed. No routing rule, no exit code,
no assertion text. Every test that was green at
449d2981ais green here,running from its new location against the same production code.
File → path
src/pool_router.pyskills/worker-pool/scripts/pool_router.pysrc/pool_route_handler.pyskills/worker-pool/scripts/pool_route_handler.pytests/pool-router-pass.test.pyskills/worker-pool/tests/pool-router-pass.test.pytests/pool-route-handler.test.pyskills/worker-pool/tests/pool-route-handler.test.pytests/pool-route-retry-driver-dispatch.test.shskills/worker-pool/tests/pool-route-retry-driver-dispatch.test.shtests/pool-route-marker-failure-recovery.test.shskills/worker-pool/tests/pool-route-marker-failure-recovery.test.shtests/pool-worker-inbox-composition.test.shskills/worker-pool/tests/pool-worker-inbox-composition.test.shBoth moved scripts pick up the skill bootstrap every other script in this skill
uses — siblings on
sys.path, the core'ssrc/atparents[3]:docs/src-map.mdis regenerated and now carries no router entry — net zeroagainst the parent (
git diff origin/feat/spawn-worker-launcher -- docs/src-map.mdis empty).
skills/worker-pool/SKILL.mdgains both scripts in its table, thenew seam, and drops #4110 from the pending list;
manifest.json's descriptionfollows. The handler declares no inline tool, so no
toolsblock.How the Stop hook reaches
--parkednowIt was a path —
"$PYBIN" "$REPO_DIR/src/pool_route_handler.py" --parked— whichafter the move would be a core file reaching into a skill. It now asks the
handler the pool's install already named, the same one the watcher probes, runs,
and drives
--retry-passon:Four answers, four dispositions — all four pinned by the new
tests/check-pending-tasks-parked-handler-seam.test.sh:The last line is the boundary assertion: the shipped hook is grepped for
pool_route_handlerandskills/worker-pooland must hit neither.What stayed core, and why
The line is who reads it with zero workers configured.
src/pool_delivery.py— the delivery-record grammar. The watcher resolvesa sentinel to its payload through its
payloadverb on everydelivery-inbox event, and the Stop hook asks
--heldon every task. Both runwith no skill installed.
finish_handler_task'sUNSETTLEDrc-5 branch (src/watch-tasks-stream.sh)— a protocol number. Its comment no longer names a file.
--retry-passcall on a declined event andSUTANDO_TASK_EVENT_HANDLERforwarding in
src/agent/claude/cli/start-cli.sh— the core's own envallowlist, gated on a variable the core never sets.
held_by_worker/--held/HELD_UNKNOWN—pool_delivery's, unmoved.The boundary grep in
SKILL.mdis widened to cover the two new names and comesback empty:
CI discovery
The Python step already scans
skills(#4108,0a9596250). The shell stepscanned
testsonly, so the three moved.test.shfiles would have goneunrun — a silently un-gated suite. It now uses the same optional-root form:
find skills -name '*.test.sh'returns exactly those three, so no other suiteis newly gated by this change.
Checks at
43a74e2d3The two
FAIL kill_workers readinesslines insidewatch-tasks-stream-dead-worker-reap.test.pyare that suite's own deliberatenegative cases (it prints "the two 'FAIL kill_workers readiness' lines below are
DELIBERATE"); its exit code is 0.
Not run: anything that starts, signals or restarts a process on this host — this
box runs the live core, four watchers and two workers.
prose_extsis['.py'], so the gate reads no shell. Hand-counted on thecumulative diff: one 3-line in-body block in
src/watch-tasks-stream.shand onein
tests/start-cli-task-event-handler-env.test.shwere trimmed to 2. The sevenremaining >2-line runs are all file headers directly under the shebang — the
same shape every sibling shell suite on this branch and on
maincarries.Deploy order
Reviews at earlier heads
The reviews on
64916421a,6b632f09dand449d2981aare reviews of thissame behaviour at the old paths. Nothing they examined changed: the router
rules, the handler's exit codes, the retry stores (
state/pool-route-retry/andtasks/.pool-route-retry/— workspace state, reachable from a skill), themarker logic and every assertion are byte-identical modulo the
sys.pathbootstrap and the test-local
$REPO/$SCRIPTSspellings. The one behaviouraldelta is the hook's
--parkedseam above, which is new and separately pinned.The #4167 note above still stands:
src/check-pending-tasks.shstill expectsto be reworked onto #4167's version of the loop once #4167 lands — now including
the
SUTANDO_TASK_EVENT_HANDLERcall in place of the old path.