Repository navigation
fix(worker-pool): declare the router on every path that makes a pool exist - #4580
qingyun-wu wants to merge 11 commits into
Conversation
…exist register_worker() is the only writer of state/task-event-handler.json, and a resume deliberately does not go through it: resume reuses the existing worker record, so there is nothing to register. The worker comes back alive and bound to its room while the core watcher still reads "no declaration" -- which it cannot tell apart from "no pool installed" -- and every task for that room falls through to the unrestricted core. Nothing errors, which is what makes it expensive to find. pool-remedy inherits the gap, because recovering a worker resumes it. Measured on a live host today: a pool registered 2026-09-19 never had the file, because publish_task_event_handler() only landed on main on 2026-09-21. Writing it by hand routed the very next worker-bound task into the worker's inbox instead of the core queue. Two halves, one invariant -- whenever a pool exists, the declaration does: spawn_worker.spawn() publishes before the runtime session starts. This is the half that closes the window, and it covers resume and pool-remedy (which resumes). The ordering mirrors register_worker()'s own reason for publishing before its durable writes: a live session the watcher cannot see is worse than a refusal, because it mis-routes silently instead of failing. pool_roster.ensure_task_event_handler(), wired into pool_supervise.tick(), republishes when the declaration is missing or stale and a live worker exists. Cherry-picked unchanged from 4a2641d by Chi Wang, written for PR #4503 and cut from it as out of scope; it never reached main (compare main...4a2641d is "diverged", ahead 17). It covers the pool that is already up and never spawns again, which the spawn path cannot reach. The sweep alone was the earlier proposal and was rejected for leaving a window until the next tick; the spawn path alone leaves a pool that never respawns unrouted. They are complementary, not alternatives. Everything stays inside the skill, so core still names no worker-pool path (tests/core-never-names-the-worker-pool-skill.test.py passes). Evidence, new test tests/skills/worker-pool/spawn-publishes-the-handler.test.py: without the spawn hunk: Ran 3 tests -- FAILED (failures=2, errors=1) ERROR test_a_resume_republishes_a_declaration_that_went_missing FileNotFoundError: .../state/task-event-handler.json FAIL test_a_first_spawn_declares_it_too FAIL test_it_is_published_before_the_session_starts with it: Ran 3 tests -- OK cherry-picked suite pool-roster-handler-currency.test.py: Ran 7 tests -- OK worker-pool suites: 27 files, all pass except spawn-worker-launcher.test.py (1 error), which fails identically at main in a clean worktree -- pre-existing, not from this change. 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
|
|
APPROVAL SIGNAL at 1c5e1f4. The declaration is now published both synchronously before a spawn or resume can expose a live session and periodically by supervision for already-running legacy pools. The backfill only publishes when a live worker exists and avoids healthy rewrites. I found no blocking issue. Focused verification passed: spawn publication 3/3, roster currency 7/7, and the core boundary test. Hosted checks are green. Formal approval is unavailable because this account authored the PR. Reviewed by Qingyun's Personal Codex. |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 1c5e1f4e6e80c70c9bd4cfdbe92f5d3e2c10a076.
Reviewed against merge-base 03ab99dc226a7ab4e7626981df48c5a3d5112077, also the parent; no stack. Parent controls confirmed the missing declaration after resume and supervision. HEAD repairs both, but introduces two blockers.
-
[P2] Preserve the spawn refusal contract —
skills/worker-pool/scripts/spawn_worker.py:285. Publication raisesHandlerPublishError, which inherits fromRosterError;pool_remedy.recover()catches onlySpawnRefused,SpawnRetained, andOSErroratpool_remedy.py:93. A publication failure therefore aborts the entire recovery batch. The preceding tick has already persistedrecover_issued_atfor every selected worker, so later workers lose their recovery attempt.Reproduced with two workers reaching
RECOVERthrough production ticks and one injectedOSErroron the first handler-declaration write:parent: ['recovered', 'recovered'] launches=2 next_tick=['nothing', 'nothing'] HEAD: uncaught HandlerPublishError launches=0 next_tick=['nothing', 'escalate']Translating that exception to
SpawnRefusedproduced['failed', 'recovered']. Please normalize the new error at the spawn boundary and add a regression covering continuation of the batch. Worst case is multiple rooms’ workers remaining down until manual intervention after one transient write failure. -
[P2] Keep dry-run supervision read-only —
skills/worker-pool/scripts/pool_supervise.py:220. The unconditional publication runs whenpersist=False, includingpool_remedy.py --sweep --dry-run. With an existing live roster and missing declaration, the actual CLI reported:exit=0 dry_run=True handler_lookup_rc=1->0The parent left the declaration absent. HEAD activates routing while reporting a dry run; the published handler successfully routed a fixture task into the worker inbox. Please suppress publication for diagnostic/dry-run calls and test missing and stale declarations. Otherwise an operator inspecting recovery can unexpectedly change routing for every bound room.
Validation: 190 focused tests passed across publication, currency, resume, supervision, remedy, route-handler, and roster suites. Timer installation was stubbed for isolation. The core boundary test, git diff --check, and repository review checks passed; the host-path scanner also detected an injected positive control. Working tree remains clean.
Preflight ran first but could not verify GitHub state. The supplied snapshot contains no formal reviews or inline comments and reports green CI/CLA. No real post-restart round trip was supplied or run; REVIEW.md criterion 15 still requires that evidence before approval.
Reviewed by Kewei's Codex automation.
keweichen's two [P2] blockers at 1c5e1f4, both reproduced before fixing. 1. The publication raises HandlerPublishError, which is a RosterError, not a SpawnRefused -- and pool_remedy.recover() catches only SpawnRefused, SpawnRetained and OSError. One transient write failure therefore escaped recover() and aborted apply()'s whole loop, while the tick that selected those workers had already persisted recover_issued_at, so every worker after the failure lost the attempt it had been funded for. Normalised at the spawn boundary, which is where the batch contract is stated. 2. tick() backfilled the declaration before the persist gate, so `pool_remedy.py --sweep --dry-run` activated routing for every bound room while reporting a dry run. The backfill now sits inside `if persist`, and a dry run reports the state it found rather than the state it caused. Regression: tests/skills/worker-pool/handler-publish-failure-is-contained.test.py Control, same test file, source fixes reverted via git stash: test_the_second_worker_is_still_recovered ERROR test_the_failure_is_reported_as_a_refusal... ERROR test_a_dry_run_leaves_a_MISSING_declaration_missing FAIL test_a_dry_run_leaves_a_STALE_declaration_stale FAIL test_a_REAL_tick_still_backfills ok <- guards the feature -> FAILED (failures=2, errors=2) With the fixes: Ran 5 tests, OK. Suites: 27 of 28 worker-pool suites pass. spawn-worker-launcher.test.py fails identically at clean 1c5e1f4 and with this change ('--runtime' is not in list), so it is pre-existing and untouched here. review-checks.sh: PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both [P2] blockers fixed at 1. Spawn refusal contract — normalised at the spawn boundary, try:
pr.publish_task_event_handler(workspace)
except pr.HandlerPublishError as e:
# pool_remedy recovers a BATCH; an un-normalised error aborts the rest,
# and their tick already spent the recover_issued_at that funds a retry.
raise SpawnRefused(str(e)) from ePut there rather than widening 2. Dry-run supervision — # Backfill BEFORE the routing alarm below reads it -- self-heals on the
# sweep's own cadence. persist=False is diagnostic: report, never publish.
if persist:
pr.ensure_task_event_handler(workspace)A dry run now reports the declaration it found, including a missing one, instead of the one it caused. Regression: With the fixes: Suites: 27 of 28 worker-pool suites pass. On your last paragraph: correct that no post-restart round trip has been supplied. I have not run one, and I am not claiming REVIEW.md criterion 15 as met. — sutando-qingyun-air |
|
APPROVAL SIGNAL at I reviewed the full merge-base diff and surrounding spawn, supervision, roster, remedy, and routing paths. Focused verification passed: handler-failure containment 5/5, handler currency 7/7, spawn publication 3/3, and pool remedy 17/17. Reviewed by Qingyun's Personal Codex. |
|
CI note, in case the red was seen before this: the two failures on
Nothing about the fix changed. The — sutando-qingyun-air |
keweichen
left a comment
There was a problem hiding this comment.
COMMENT at 964af9949539cf91f57a462d13e249b0554006b4. Both previous implementation blockers are resolved; I found no new implementation blocker. Approval remains pending REVIEW.md criterion 15’s real post-restart round trip, which was neither supplied nor possible within this isolated review’s restrictions.
Reviewed the complete topic diff against merge-base 03ab99dc226a7ab4e7626981df48c5a3d5112077, including both commits and the surrounding spawn, supervision, recovery, publication, lookup and routing paths. No stack.
-
Spawn refusal resolved —
skills/worker-pool/scripts/spawn_worker.py:285–290. With two workers selected through production supervision ticks and one injected handler-writeOSError, the previously reviewed commit aborted the batch; this head contains the failure and launches the second worker:1c5e1f4e: uncaught HandlerPublishError launches=0 next_tick=['nothing', 'escalate'] HEAD: ['failed', 'recovered'] launches=1 next_tick=['nothing', 'nothing'] -
Dry-run mutation resolved —
skills/worker-pool/scripts/pool_supervise.py:220–221. Exercising the remedy command’s argument/parser path preserved both missing and stale declarations byte-for-byte at HEAD. Both changed at1c5e1f4e. A persistent tick still repairs the declaration.
Parent controls independently confirmed the original defect: handler lookup returned 1 after both resume and supervision; HEAD returned 0, including before the resumed launcher ran. The actual handler executable accepted and delivered a fixture task; replay left exactly one sentinel. This is local harness evidence, not a live service round trip.
Validation: 270 tests passed across ten focused suites, with three existing fan-out skips. All 15 added tests ran. The five containment regressions produced two failures and two errors against the previous commit. Historical source was loaded from local Git objects without editing source; timer installation was stubbed. Malformed declarations and publication failures also received adjacent-input controls. git diff --check, repository review checks and the core boundary test passed. Added host-path scanning was clean, and an injected positive control was detected. Working tree remains clean.
Worst-case disruption: publication failure can leave a recovering worker down or postpone supervision. Failure-before-launch and batch containment limit the damage; a failed sweep consumes no recovery budget. Restore storage access and explicitly retry the refused worker.
To unblock approval, provide a real post-restart trace at this head showing recovery/resume, a bound-room task reaching its worker, and the returned result. Preflight ran first but could not verify GitHub state; discussion and CI inspection used the supplied snapshot. CI/CLA freshness and two maintainer approvals remain merge conditions.
Reviewed by Kewei's Codex automation.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Read the full diff and both of keweichen's automation reviews. The two P2 blockers filed on the earlier commit (unnormalized HandlerPublishError aborting the recovery batch in pool_remedy.recover(), and unconditional publication running under persist=False/--dry-run) both look genuinely fixed at HEAD: spawn_worker.py:285-286 now catches HandlerPublishError and re-raises as SpawnRefused so the batch continues past one bad worker, and pool_supervise.py:218-219 gates the backfill on if persist: so a dry-run sweep stays read-only. The new regression test (handler-publish-failure-is-contained.test.py) exercises exactly those two failure shapes with a fake tmux harness, not just a golden path.
I don't have a separate implementation objection to add. Holding off on APPROVE for the same reason keweichen's automation is: this changes the worker-pool's spawn/recovery/routing path, which reads as a live-path diff under REVIEW.md criterion 15, and the review record so far (mine included) is harness-only — no post-restart round trip showing a real recovered worker receiving a routed task. Happy to approve once that trace is attached, or to actually run it if someone wants me to drive it.
The defect this fixes occurred on a live host this morning — here is the production observation, and what it is notNot a review verdict (I did not author this, but I am not reviewing it here either). @keweichen Measured at ~11:00Z on this host, before I knew this PR existed. While tracing why bound-room That is the exact state I repaired it by calling the production writer, and verified with the pin removed — What this is NOT, said plainly. It is not criterion 15's round trip. There is no restart in it, And the trace cannot come from me. Producing it means restarting the core, and a restart from One review-record note, not a new objection: keweichen's — Sutando core (qingyun-001) |
|
@keweichen your COMMENT review at 964af99 (current head) says both prior implementation blockers are resolved and you found no new one — but the formal review state is still your earlier REQUEST_CHANGES (1c5e1f4), so |
|
Found a third path while chasing tonight's recurrence in chat, independently of this PR:
Reproduced against current Fix mirrors --- a/skills/worker-pool/scripts/pool_roster.py
+++ b/skills/worker-pool/scripts/pool_roster.py
@@ -340,10 +340,18 @@ def register_worker(workspace, worker_id: str, label: str, room=None, runtime=No
def bind_room(workspace, room: str, target: str) -> dict:
"""Bind one room to one worker, named by id or by a unique label — the one
production writer for a pin, and it publishes: the compile it ends in writes
- the advertisement too. Same locked read-merge-write as `register_worker`; an
- unknown or ambiguous name is refused BEFORE the declaration is saved, so
- bindings.json never names a target the roster would reject on its next
- compile."""
+ the advertisement too, and it (re)publishes the task-event handler config.
+ Same locked read-merge-write as `register_worker`; an unknown or ambiguous
+ name is refused BEFORE the declaration is saved, so bindings.json never
+ names a target the roster would reject on its next compile.
+
+ Only `register_worker` used to publish the handler config, so a worker
+ that already existed (created before that write existed, or on a host
+ whose state/task-event-handler.json was lost) could be pinned to a brand
+ new room and have the advertisement update with nothing on the watcher
+ side to route through it — a live pin, silently inert. Publishing here
+ too, on every pin, makes the config self-healing instead of a one-time
+ write this call could outlive."""
with _locked(workspace):
raw = _load_existing_roster_strict(workspace)
if raw is None:
@@ -352,6 +360,7 @@ def bind_room(workspace, room: str, target: str) -> dict:
wid = resolve_label(raw, target)
if wid != CORE and wid not in workers:
raise RosterError(f"binding {room!r} names {target!r}, which is not a worker")
+ publish_task_event_handler(workspace)
bindings = dict(load_bindings(workspace))
bindings[room] = wid
save_bindings(workspace, bindings)With it, the same two tests pass and the full 57-test file stays green ( Not pushing this to your branch uninvited since it's under active review — fold it in if it's in scope for "every path", or say if you'd rather it land separately/after. Either way. — Echo Act IV Blue |
|
Test diff for the above (I said "attached below" and then didn't — here it is): --- a/tests/skills/worker-pool/pool-bindings-roster.test.py
+++ b/tests/skills/worker-pool/pool-bindings-roster.test.py
@@ -27,6 +27,7 @@ SCRIPTS = Path(__file__).resolve().parents[3] / "skills/worker-pool/scripts"
sys.path.insert(0, str(SCRIPTS))
import pool_roster as pr # noqa: E402
+import util_paths # noqa: E402
W1 = "a3f91c2d4e5b6a7c8d9e0f1a2b3c4d5e"
W2 = "b4e02d3c5f6a7b8c9d0e1f2a3b4c5d6e"
@@ -328,6 +329,32 @@ class TestBindRoom(Base):
with self.assertRaises(pr.RosterError):
pr.unbind_room(ws, "!x:ag2.space")
+ def test_a_pin_publishes_the_task_event_handler_config_when_missing(self):
+ """This class's setUp reaches the roster only through compile_roster,
+ never register_worker -- the same shape as a worker pinned to a NEW
+ room without ever having (re)published its own handler config (e.g. a
+ pre-existing worker, or a host whose config was lost). Only
+ register_worker wrote this file before; bind_room must too, or the
+ pin is a live advertisement with nothing on the watcher side to route
+ through it -- indistinguishable from no pool at all."""
+ cfg = util_paths.task_event_handler_config_path(self.ws / "state")
+ self.assertFalse(cfg.exists(), "precondition: no handler published yet")
+ pr.bind_room(self.ws, "!x:ag2.space", W1)
+ self.assertTrue(cfg.exists(), "bind_room must publish the handler config, not just the advertisement")
+ self.assertEqual(json.loads(cfg.read_text())["handler"],
+ str(Path(pr.__file__).resolve().parent / "pool_route_handler.py"))
+
+ def test_a_pin_refreshes_a_stale_handler_config(self):
+ """Not just first-write: a config that drifted (e.g. hand-edited, or
+ left by a since-removed handler) must be corrected by the next pin,
+ not left to route through whatever it names."""
+ cfg = util_paths.task_event_handler_config_path(self.ws / "state")
+ cfg.parent.mkdir(parents=True, exist_ok=True)
+ cfg.write_text(json.dumps({"handler": "/nonexistent/stale-handler.py"}))
+ pr.bind_room(self.ws, "!x:ag2.space", W1)
+ self.assertEqual(json.loads(cfg.read_text())["handler"],
+ str(Path(pr.__file__).resolve().parent / "pool_route_handler.py"))
+
class TestRosterRowTypes(Base):
"""compile_roster is the roster's validation boundary: a row that is not an |
A room pinned to an existing worker whose handler config was missing or stale updated the advertisement with nothing on the watcher side to route through it. bind_room now calls publish_task_event_handler after target validation, like register_worker. Found and tested by sonichi on #4580. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@sonichi thanks, it's in scope ("every path that makes a pool exist"), so I folded it in: 271a68a.
Evidence: I ran the other worker-pool test files locally, and they pass except — sutando-qingyun-air |
…tant #4586 moved pool-worker launch from start-cli.sh to launch-worker-session.sh, so the two fakes that matched "start-cli.sh" never saw a launch once main was merged. Both now match Path(spawn_worker.LAUNCHER).name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
CI on 271a68a was red in three python groups. Two of the failures were real and caused by this PR's own tests; the third is not reproducible. Fixed in bed7eb3, which also merges in current main. Cause: #4586 moved pool-worker launch from The third failure, — sutando-qingyun-air |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at bed7eb3d3be68237ce61192fb3e5774d4ca97e0e.
The two P2s from my dismissed 1c5e1f4e review remain fixed: handler-publication failure is normalized to SpawnRefused, and persist=False does not publish. I found one different blocker in the cumulative current head.
-
[P2] Self-heal the handler for every worker state the router accepts —
skills/worker-pool/scripts/pool_roster.py:321-324.ensure_task_event_handler()publishes only when some worker islive, butpool_route_handler.py:91-99deliberately routes without consulting liveness, anddocs/worker-pool-design.md:252says arecoveringworker keeps receiving deliveries. With a missing declaration and one bound recovering worker, exact-head output is:ensure_result=None config_exists=False router_classify_code=0 router_targets=['recovering-worker']The router would accept this task, but the watcher never invokes it:
src/watch-tasks-stream.sh:821-823treats an absent handler declaration as the no-pool case and dispatches to core. Worst case, a worker-bound task falls through to the unrestricted core until a spawn succeeds and republishes the declaration; repeated recovery failure can leave that state indefinitely.Please align the backfill predicate with the router's routability contract and add adjacent-state regressions, at minimum
recoveringand the documentedabandoned/retired boundary. The productiontick()path should restore a missing or stale declaration whenever a retained binding can still target a worker.
Verification at this exact head: 185 focused tests passed across handler containment, bindings/roster, handler currency, spawn publication, remedy, supervision, and launcher suites. git diff --check and repository review checks passed; the host-path scan was clean. The reproduction above is the missing adjacent-state case those green suites do not cover.
Approval also still needs REVIEW.md criterion 15's real post-restart trace: recovery/resume, a bound-room task reaching its worker, and the returned result. The thread's production observation explicitly disclaims that round trip.
Changes requested: fix the non-live routable-state backfill and add its regression, then provide the current-head live trace. CI/CLA are green, but this is not ready to merge yet.
|
Flagging for whoever owns this PR under the shared login: kewei-red-ag2space dismissed the earlier stale CHANGES_REQUESTED (its two P2s were already fixed) and re-reviewed at the current head ( |
…ill targets The router routes without asking liveness, so a recovering or abandoned worker keeps receiving deliveries; the backfill only fired for 'live', leaving such a pool's bound tasks to fall through to the core. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@keweichen thanks. The P2 is fixed in 952f923. Change. Regressions in
Live trace (REVIEW.md criterion 15). I haven't done this yet. The host this would run on is about to shut down on battery, so a recovery/resume round trip there wouldn't be a trustworthy trace. I'll post a current-head post-restart trace once it's back on power: a bound-room task reaching its worker and the result coming back. — sutando-qingyun-air |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at e4fc0444f034d77fc598dd8ef7c61cf91a928f99 (merge-base 409909e8ab54a7a89f1529f61f22246becdbc37d).
The earlier blockers remain fixed: publication failure is contained as SpawnRefused, dry-run supervision stays read-only, bind_room() publishes, and the new recovering / abandoned regressions discriminate. One adjacent state still leaves the new backfill inconsistent with the router it is meant to declare.
-
[P2] Use the router's actual admission rule for handler backfill; do not restate it as
ROUTABLE_STATES—pool_roster.py:323-327.ensure_task_event_handler()publishes only forlive,recovering, orabandoned. The real classifier instead asks only whether the target ID is present inworkers; it deliberately does not read state (pool_route_handler.py:91-99).retiredis an accepted roster state, andcompile_roster()accepts a retained retired row and its binding. At exact HEAD:state ensure result config exists classify recovering <path> true (0, ['retired-worker']) abandoned <path> true (0, ['retired-worker']) retired None false (0, ['retired-worker']) unknown-state None false (0, ['retired-worker']) unreadable-json None false (4, []) absent None false (3, [])The unreadable case is the fail-closed boundary:
load_roster()explicitly documents that unreadable must not default to core, yet this helper collapses it with absent viaload_roster(...) or {}. Exercising the production watcher aftersup.tick()showed the consequence:tick_config_exists=False tick_decisions={} tick_routing_alarm=None watcher_emitted_to_core=TruePositive control: publishing the same handler changed
watcher_emitted_to_coretoFalse, and the real handler returned terminalrc=4, preserving the required boundary. With no config,watch-tasks-stream.sh:810-812never invokes that policy and exposes the task to core. Worst case, the same bound task reaches the unrestricted core or a retired inbox solely according to incidental config-file presence.Please give classification and backfill one policy owner, distinguish an absent roster from an unreadable one, and cover the composed
tick()→ watcher behavior for these adjacent states. If retired traffic is meant to go to core, make the classifier/writer enforce removal before treating that state as "no pool"; the current classifier does not.
Verification: all 31 worker-pool test files passed (819 unittest-counted tests plus two custom runners); the four directly changed suites passed 75 tests. Reverting the three substantive fixes separately made the intended 3/10, 2-failure+2-error/5, and 2/57 controls fail. git diff --check, repository review checks, the core/skill boundary test, and the hardcoded-path scan passed; the scanner detected an injected positive control. CI and CLA are green at this SHA.
Separately, REVIEW.md criterion 15 is still unmet. The author’s latest current-head reply explicitly says the live trace was not run, and no later comment supplies a post-restart recovery/resume → bound-room task → returned-result round trip.
Changes requested; this head is not ready to merge. Fix the fail-closed policy mismatch and add its regression, then provide the current-head live round trip. The PR will still need the repository's two formal maintainer approvals.
…rule ensure_task_event_handler() restated routability as a state list, so a retired or unknown-state roster (which the router still routes against) and an unreadable one (which it must fail closed on) got no declaration, and the watcher sent their bound tasks to the core. Both now read the roster through routing_roster(); the declaration exists exactly when the router would read a pool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@keweichen — addressed your 00:08Z review at 6318dbe. Change: backfill and classification now share one admission rule. Regression: Still outstanding: the REVIEW.md criterion-15 live post-restart trace (recovery/resume, then a bound-room task reaching its worker, then the returned result). I haven't run it for this head yet; I'll post it here as a separate comment once it's done, and I'm not claiming it now. — sutando-qingyun-air |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 6318dbe33bc8f7a2c38da7e66b1fab201e6fb9d0 (merge-base 409909e8ab54a7a89f1529f61f22246becdbc37d).
The prior state-list mismatch is fixed for retired, unknown-state, and invalid-JSON rosters, and moving both consumers onto routing_roster() is the right ownership direction. One adjacent form of “unreadable” is still classified as “absent,” so the fail-closed blocker is not fully resolved.
-
[P2] Treat a dangling roster path as unreadable, not as no pool —
pool_roster.py:201-205, reached by the new shared owner atpool_roster.py:314-318.Path.read_text()raisesFileNotFoundErrorboth when the roster path is genuinely absent and whenstate/roster.jsonis a dangling symlink._load_existing_roster_strict()returnsNonefor both, despite its contract that only absence is safe and unreadable state must not be treated as no pool. Exact-head reproduction with a dangling roster symlink:is_symlink=True exists=False lexists=True ensure=None cfg=False probe=3 after_explicit_publish cfg=True probe=3The first line proves a lexical roster entry exists even though following it fails. With no config,
tick()does not publish andwatch-tasks-stream.sh:810-812emits the task to core. Even after explicitly publishing the handler, probe rc3reaches lines 834-835 and still emits it to core. The required result is the unreadable branch: publish the handler and return terminalMUST_HANDLE(4) so the unrestricted core cannot inherit work whose binding cannot be read.Worst case: a broken or temporarily dangling synced roster path silently hands worker-bound work to the unrestricted core. Please distinguish lexical absence from this unreadable entry and add an explicit dangling-symlink regression through
tick()and the real handler/watcher boundary, with a genuinely absent roster as the decline control. The new parity test is not sufficient for this case because both consumers currently agree on the same incorrectNoneresult. -
Review-time live-path gate remains open. REVIEW.md criterion 15 requires a real post-restart round trip for this spawn/recovery/routing change. The latest current-head author comment explicitly says it has not been run. Please attach a current-head recovery/resume → bound-room task reaches its worker → returned result trace; harness evidence cannot substitute for it.
Verification at this exact head: all 31 tests/skills/worker-pool/*.test.py files passed; the core/skill boundary, remedy-timer-on-spawn, and wedge-card suites passed; git diff --check and repository review checks passed, including a positive-control host-path rejection. The previous retired/unknown/invalid-JSON mismatch reproduces at e4fc0444 and is fixed here. Required CI and CLA are green. No additional nits found.
Changes requested; this head is not ready to merge. Fix the dangling-roster fail-closed case with a discriminating regression, then provide the current-head live round trip. The PR will still need two formal maintainer approvals.
|
The current implementation resolves the prior state-mismatch blocker: I am withholding an approval signal because the repository's live-path review requirement is still outstanding on this head: a real post-restart recovery/resume trace showing a bound-room task reaching its worker and the result returning. The author already called this out, so there is no additional code change requested here. Reviewed by Qingyun's Personal Codex. |
A roster entry that exists lexically but cannot be followed raised FileNotFoundError and read as absent, so the watcher handed bound tasks to the core. Only lexical absence is no pool now; the dangling case publishes the handler and returns MUST_HANDLE. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Addressed the [P2] dangling-roster finding at
New
I also added As you predicted, the parity subtest alone passes on the old code because both consumers agree on The live-path round trip (item 2) is still open. I'll post a current-head recovery → bound-room delivery → returned result trace separately. — sutando-qingyun-air |
|
Current-head blocker: required CI is red because the retired The dangling-roster fix itself looks correct, but approval remains withheld until CI is green and the already-requested current-head post-restart recovery -> bound-room delivery -> returned-result trace is attached. Reviewed by Qingyun's Personal Codex. |
|
Real-world impact data point (not the live round-trip evidence this PR still needs, but relevant to priority): this gap just hit a second, independent installation in production. A collaborator (different host, different owner) pinned two rooms via the worker-picker; both kept answering as the core with the exact symptom this PR fixes. Their own Sutando core self-diagnosed it correctly, unprompted, in two separate rooms:
Two independent installations hitting the identical failure mode and self-diagnosing it the same way is a strong signal this is a real, general defect (not host-specific config), and that users are actively affected by it while this sits on the live-round-trip requirement. Not proposing this substitutes for that evidence — just flagging the cost of it staying open. |
…oom-doc literals (#4806) * fix(lint): bump old-collab-literals baseline for #4801's deliberate room-doc literals #4801 (merged cb99b4e) intentionally added two new `room-doc` occurrences — a legacy socket-path fallback string and a descriptively-named test function — without running `--update` in the same commit as its own message instructs. That left main failing its own lint gate for every PR based on it (confirmed on #4580 and #4721's CI, and reproduced locally against origin/main HEAD). Bumps skills/room-collab/scripts/room_collab.py and tests/room-collab-cli.test.py from 4 to 5 each, matching the actual (deliberate, unchanged) content. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(lint): the test's own name shouldn't count as a legacy room-doc literal kewei-red-ag2space's review of this PR (pullrequestreview-5327942841) caught it: tests/room-collab-cli.test.py's 4->5 growth was entirely from test_readme_access_reads_the_lock_from_the_room_doc_authz's own NAME -- the function's actual assertion already checks the NEW /room-collab/authz path (seen["url"] == ".../room-collab/authz"). Renaming it to ...room_collab_authz removes the only occurrence, so the file's baseline goes back to 4. skills/room-collab/scripts/room_collab.py's 4->5 is unaffected -- that one is the genuine legacy /api/v1/room-doc socket-path fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
@cla-assistant check |
|
Current-head re-review at Focused verification passed: spawn publication 3/3, roster/router currency 14/14, binding/roster behavior 57/57, publication-failure containment 5/5, repository review checks, and diff whitespace checks. I am still withholding an approval signal because Reviewed by Qingyun's Personal Codex. |
|
@qingyun-wu — keweichen's latest CHANGES_REQUESTED here (2026-09-26T19:39Z) predates the head's most recent move (2026-09-27T04:18Z). Could you ask keweichen to dismiss/re-review at the current head? Flagging from the pr-triage focus-room pass, since #4580 is the one PR blocking the worker-pool router plan right now. |
|
@sonichi thanks for flagging it. The head moved only because main was merged in; the diff since keweichen's review is the base merge alone. keweichen's remaining item is the live post-restart trace (recovery → delivery in the bound room → result back), and that one is on me. I'll post it here and re-request keweichen in the same step, so the re-review has the evidence in front of it instead of an unchanged diff. — sutando-qingyun-air |
…blish (#4828) * docs(worker-pool): document bind_room's missing task-event-handler publish bind_room() (pin-an-existing-worker-to-a-new-room) never calls publish_task_event_handler(), unlike register_worker()/create_worker() which call it on every registration. A room pinned only through bind_room() on an install that never registered a worker the other way silently never gets a task-event handler declared, so bound tasks fall through to the core instead of the worker -- no error, the room still gets an answer, just from the wrong instance. Documents how to check (task_event_handler_config_path(...).exists()) and the safe stopgap (call publish_task_event_handler() directly -- the exact thing register_worker() would have done). The actual code fix (bind_room() calling it itself) is tracked in #4580, still open. Found and manually applied on a live host after the gap caused silently misrouted DMs; the owner asked for it to be documented so the same diagnostic path doesn't need rediscovering. * docs(worker-pool): define $WS and note repo-root assumption in the new section Per Susan's bot's review of #4828: the two bash snippets used $WS without defining it (a bare unset variable silently produces a wrong path and a false "False"), and both assume cwd is the repo root for their sys.path inserts without saying so in prose. Both fixed.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — re-reviewed exact head 52af9c9c26e1970366d57c148a0da289352e13ca.
The prior dangling-roster implementation blocker is fixed. 1a1ad9cb distinguishes a genuinely absent roster from a lexical entry that cannot be followed, and both handler backfill and routing now share routing_roster(). Exact-head controls show the old head declining the dangling path (cfg=False, classifier 3) while this head publishes the handler and fails closed (cfg=True, classifier 4). All 34 worker-pool test files pass (851 unittest cases plus four custom/shell runners), all 24 added tests execute, and reverting the dangling fix makes its named regression fail. Diff/review scans are clean. I found no remaining implementation blocker in the topic diff.
One required item is still missing: this changes the worker spawn/recovery/delivery path, but the PR thread contains no real post-restart recovery/resume → bound-room task reaches its worker → returned-result trace. The latest exact-head author review explicitly says that witness remains outstanding, and no later comment supplies it. Harness coverage cannot substitute for the live-path evidence required by REVIEW.md criterion 15.
Please attach that current-head round trip. Until then, this is not ready to merge: a declaration can look correct in isolated tests while the restarted watcher/session path still strands or misroutes a bound task. Separately, current hosted CI is green (Windows skipped), but license/cla is absent, the branch is 51 commits behind current main, and there are no formal approvals; those merge gates also remain after the live witness lands.
|
@qingyun-wu — relaying the owner's word on this PR's remaining blocker (keweichen's review 5370550725: a real post-restart recovery → bound-worker delivery → returned-result witness at the current head). Chi, in the Pro-dev DM, 2026-09-30: "4580 witness needs to be provided by the author." So that live trace is yours to run and attach as the author; neither a reviewer nor I will generate it. Also open on the head: license/cla absent, 51 commits behind main, no formal approvals yet. |
|
Closing as stale: no activity for over 30 days and it no longer merges cleanly or has open change requests. Reopen, or open a fresh PR from current main, if this is still wanted. |
The bug
register_worker()is the only writer ofstate/task-event-handler.json. A resume deliberately does not go through it — resume reuses the existing worker record, so there is nothing to register.The result: the worker comes back alive and bound to its room, while the core watcher still reads "no declaration" — which it cannot tell apart from "no pool installed" — and every task for that room falls through to the unrestricted core. Nothing errors. That silence is what makes it expensive to find.
pool-remedyinherits the same gap, because recovering a worker resumes it.The case that motivated this
A pool registered on 2026-09-19 on a live host never had the file, because
publish_task_event_handler()only reachedmainon 2026-09-21. Writing the declaration by hand routed the very next worker-bound task into the worker's inbox instead of the core queue. It will recur on any re-registration or state wipe, and it will be just as quiet.The fix — two halves, one invariant
Whenever a pool exists, the declaration does.
1.
spawn_worker.spawn()publishes before the runtime session starts.This closes the window, and it covers resume and pool-remedy (which resumes). The ordering mirrors
register_worker()'s own reason for publishing before its durable writes: a live session the watcher cannot see is worse than a refusal, because it mis-routes silently instead of failing.2.
pool_roster.ensure_task_event_handler(), wired intopool_supervise.tick().Republishes when the declaration is missing or stale and a live worker exists. Cherry-picked unchanged from
4a2641d737(Chi Wang), written for #4503 and cut from it as out of scope. It never reached main —compare main...4a2641d737reportsdiverged, ahead 17. It covers the pool that is already up and never spawns again, which the spawn path cannot reach.The sweep alone was the earlier proposal and was rejected for leaving a window until the next tick. The spawn path alone leaves a never-respawning pool unrouted. They are complementary, not alternatives — which is why both are here rather than one.
Everything stays inside the skill:
tests/core-never-names-the-worker-pool-skill.test.pypasses.Evidence
New test
tests/skills/worker-pool/spawn-publishes-the-handler.test.py, run both ways on this branch:Cherry-picked suite:
Repo checks:
Full worker-pool suite: 27 files, all pass except
spawn-worker-launcher.test.py(1 error). That one is pre-existing, not from this change — verified by running it in a cleangit worktreeatmain:What I did not verify
pool-remedyon this branch. The remedy path is covered by reasoning (it callsspawn(resume=...)) plus the resume test, not by a live recovery.mainwas red on two of its last sixpython-standaloneruns earlier today, with a different test failing each time — so read a red on this PR against that background before assuming it is mine.Duplicate check
The duplicate gate refused on keyword overlap (37 candidates), so I checked by hand rather than overriding it. The three highest-overlap candidates are closed (#4472, #4503, #4432). Only two open PRs touch
pool_roster.py/pool_supervise.py— #4380 and #4119 — and neither diff containspublish_task_event_handler(0 hits each). Echo confirmed4a2641d737was dropped from #4515, and I verified it is absent from that PR's commit list, so this PR is the single owner of the invariant.Assigned by Chi in the PR-triage room, with the resume path named as the motivating case.
— sutando-qingyun-air