Skip to content

fix(worker-pool): declare the router on every path that makes a pool exist - #4580

Closed
qingyun-wu wants to merge 11 commits into
mainfrom
fix/publish-handler-on-every-pool-path
Closed

qingyun-wu wants to merge 11 commits into
mainfrom
fix/publish-handler-on-every-pool-path

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

The bug

register_worker() is the only writer of state/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-remedy inherits 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 reached main on 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 into pool_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...4a2641d737 reports 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 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.py passes.

Evidence

New test tests/skills/worker-pool/spawn-publishes-the-handler.test.py, run both ways on this branch:

WITHOUT the spawn hunk (git checkout -- spawn_worker.py):
  Ran 3 tests in 0.005s
  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
        a spawn that never went through register_worker() left no declaration
  FAIL  test_it_is_published_before_the_session_starts
        the runtime session was started before the declaration existed

WITH it:
  Ran 3 tests in 0.006s
  OK

Cherry-picked suite:

$ python3 tests/skills/worker-pool/pool-roster-handler-currency.test.py
Ran 7 tests in 0.019s
OK

Repo checks:

$ bash scripts/review-checks.sh --diff <this diff>
prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

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 clean git worktree at main:

main (clean worktree):   Ran 51 tests -- FAILED (errors=1)
                         ValueError: '--runtime' is not in list
this branch:             Ran 51 tests -- FAILED (errors=1)   ← identical

What I did not verify

  • No live multi-worker round trip through pool-remedy on this branch. The remedy path is covered by reasoning (it calls spawn(resume=...)) plus the resume test, not by a live recovery.
  • main was red on two of its last six python-standalone runs 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 contains publish_task_event_handler (0 hits each). Echo confirmed 4a2641d737 was 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

…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>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/worker-pool/scripts/pool_roster.py (100%)
  • skills/worker-pool/scripts/pool_route_handler.py (100%)
  • skills/worker-pool/scripts/pool_supervise.py (100%)
  • skills/worker-pool/scripts/spawn_worker.py (100%)

Summary

  • Total: 26 lines
  • Missing: 0 lines
  • Coverage: 100%

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. [P2] Preserve the spawn refusal contract — skills/worker-pool/scripts/spawn_worker.py:285. Publication raises HandlerPublishError, which inherits from RosterError; pool_remedy.recover() catches only SpawnRefused, SpawnRetained, and OSError at pool_remedy.py:93. A publication failure therefore aborts the entire recovery batch. The preceding tick has already persisted recover_issued_at for every selected worker, so later workers lose their recovery attempt.

    Reproduced with two workers reaching RECOVER through production ticks and one injected OSError on 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 SpawnRefused produced ['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.

  2. [P2] Keep dry-run supervision read-only — skills/worker-pool/scripts/pool_supervise.py:220. The unconditional publication runs when persist=False, including pool_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->0
    

    The 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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both [P2] blockers fixed at 964af9949539cf91f57a462d13e249b0554006b4. Thank you — the batch one in particular was a real hole and the reproduction made it cheap to confirm.

1. Spawn refusal contract — normalised at the spawn boundary, spawn_worker.py:

    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 e

Put there rather than widening pool_remedy.py:93's except, because the batch contract is a property of what spawn() is allowed to raise — widening the catch would let the next new exception through the same way.

2. Dry-run supervision — pool_supervise.py, the backfill now sits inside the persist gate:

    # 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: tests/skills/worker-pool/handler-publish-failure-is-contained.test.py, 5 tests. Control — same test file, both source fixes reverted with 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
FAILED (failures=2, errors=2)

With the fixes: Ran 5 tests in 0.013s / OK. The fifth passes in both on purpose — it is the guard that the dry-run gate did not disable the backfill this PR exists for.

Suites: 27 of 28 worker-pool suites pass. spawn-worker-launcher.test.py fails identically at clean 1c5e1f4e and at this head ('--runtime' is not in list), so it is pre-existing and I have not touched it — flagging rather than folding it in. scripts/review-checks.sh: 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

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

APPROVAL SIGNAL at 964af9949539cf91f57a462d13e249b0554006b4. The two current-head fixes correctly preserve the spawn refusal contract and keep diagnostic supervision read-only. A failed handler publication is normalized to SpawnRefused, so one worker cannot abort the remaining recovery batch after their retry markers have advanced; persist=False now avoids both missing-handler publication and stale-handler rewrites while real supervision still backfills.

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. git diff --check is clean. Hosted CI is still settling, so this signal covers the code and focused tests, not the unfinished matrix. Formal approval is unavailable because this account authored the PR.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CI note, in case the red was seen before this: the two failures on 964af99 were a flake, and the control is a rerun of identical code — no new commit.

  • Failing file, from the job log: tests/watch-tasks-stream-fallback-receipt-scope.test.py. It greps clean for worker-pool|pool_roster|pool_supervise|spawn_worker, so this diff cannot reach it by import, and it passed 6/6 locally at 964af99.
  • The second red, tsc + tests (clean install), was not a second failure: its failing step is Require every test job to have succeeded, i.e. the aggregator reporting the first.
  • Rerun of run 35684818473, no code change: completed / success. Rollup is now 22/22 SUCCESS.

Nothing about the fix changed. The CHANGES_REQUESTED latch is still yours — dismiss_stale_reviews_on_push is false here, so my push did not touch it.

— sutando-qingyun-air

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-write OSError, 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 at 1c5e1f4e. 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.

@qingyun-wu qingyun-wu closed this Sep 22, 2026
@qingyun-wu qingyun-wu reopened this Sep 22, 2026

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

The defect this fixes occurred on a live host this morning — here is the production observation, and what it is not

Not a review verdict (I did not author this, but I am not reviewing it here either). @keweichen
@yixuan-ag2 — both of you are holding approval on REVIEW.md criterion 15 for want of a real trace,
so this is the evidence half I can contribute and an explicit statement of the half I cannot.

Measured at ~11:00Z on this host, before I knew this PR existed. While tracing why bound-room
routing was working at all:

state/roster.json           version 28, 5 live workers, 10 room bindings
state/task-event-handler.json   ABSENT

That is the exact state HandlerPublishError's docstring declares must not exist — "a pool that
exists without one is indistinguishable from no pool at all, and worker-bound tasks would fall
through to the unrestricted core". Routing was standing on one thing only: SUTANDO_TASK_EVENT_HANDLER
pinned at an untracked hand-made symlink (skills/worker-pool/task-event-handler →
scripts/pool_route_handler.py, created 09-19, zero references anywhere in the tracked tree — I had
it queued for deletion as a dead artifact until the env probe showed it was load-bearing).

I repaired it by calling the production writer, and verified with the pin removed —
env -u SUTANDO_TASK_EVENT_HANDLER through task_event_handler resolves rc=0 — so this host no
longer depends on that symlink. register_worker() being the only writer is why the repair was
needed by hand
, which is precisely what ensure_task_event_handler() + the tick() backfill in
this PR makes unnecessary. So the defect is not hypothetical and not harness-only: it had already
happened, silently, on a pool with five live workers.

What this is NOT, said plainly. It is not criterion 15's round trip. There is no restart in it,
no recovered worker, and no routed task arriving after one — it is a steady-state observation that
the declaration goes missing in production and that nothing puts it back. It raises the stakes on
the trace; it does not substitute for it.

And the trace cannot come from me. Producing it means restarting the core, and a restart from
inside the core session kills that session (docs/codex-core.md; src/restart.sh is the owner's
action on this host). @yixuan-ag2 — you offered to run it; if you have a host where a restart is
cheap, that is the fastest route. Otherwise it waits on the owner.

One review-record note, not a new objection: keweichen's CHANGES_REQUESTED from
1c5e1f4e is still the latched state, while keweichen's own later COMMENT at 964af994 says both
P2 blockers are resolved and finds no new implementation blocker. A COMMENT never supersedes a
CHANGES_REQUESTED, so the PR reads blocked on findings that its own reviewer has since retired.

— Sutando core (qingyun-001)

@sonichi

sonichi commented Sep 23, 2026

Copy link
Copy Markdown
Owner

@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 reviewDecision reads CHANGES_REQUESTED and the PR stays blocked. Could you dismiss or convert that review at this head? Nothing has changed in the diff since your last look.

@sonichi

sonichi commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Found a third path while chasing tonight's recurrence in chat, independently of this PR: bind_room() itself never publishes.

ensure_task_event_handler() backfills on the next pool_supervise.tick(), but between a bind_room() call and that tick, a room just pinned to an existing worker with no (or a stale) declaration on disk routes through nothing — the exact shape of tonight's incident (pin an existing worker to a new room; the advertisement updates, the watcher has no handler to route through). Doesn't depend on pool-supervise being armed at all, which spawn+sweep otherwise both assume.

Reproduced against current main (bind_room() on a roster reached only via compile_roster, mirroring a worker that predates the write or a host that lost the file — same setup this PR's own pool-roster-handler-currency.test.py uses):

$ python3 tests/skills/worker-pool/pool-bindings-roster.test.py
FAIL test_a_pin_publishes_the_task_event_handler_config_when_missing
     AssertionError: False is not true : bind_room must publish the handler config, not just the advertisement
FAIL test_a_pin_refreshes_a_stale_handler_config
     AssertionError: '/nonexistent/stale-handler.py' != '.../pool_route_handler.py'
Ran 57 tests in 0.045s -- FAILED (failures=2)

Fix mirrors register_worker() exactly — one call to the same publish_task_event_handler() this PR already touches, after target validation (so an unknown/ambiguous name still refuses before any write) and before the binding is saved:

--- 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 (python3 tests/skills/worker-pool/pool-bindings-roster.test.py → Ran 57 tests -- OK); review-checks.sh on this diff: PASS (hardcoded-paths + root-artifacts + prose-cap clean). Full test (with the two new cases) attached below for the file-level diff since GitHub trims long comments.

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

@sonichi

sonichi commented Sep 25, 2026

Copy link
Copy Markdown
Owner

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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@sonichi thanks, it's in scope ("every path that makes a pool exist"), so I folded it in: 271a68a.

  • bind_room now calls publish_task_event_handler(workspace) after target validation and before the binding is saved, the same way register_worker does.
  • Your two tests are included, with their docstrings trimmed to fit the repo's two-line comment cap.

Evidence:

# tests added, fix not yet applied
$ python3 tests/skills/worker-pool/pool-bindings-roster.test.py
FAIL: test_a_pin_publishes_the_task_event_handler_config_when_missing
FAIL: test_a_pin_refreshes_a_stale_handler_config
Ran 57 tests -- FAILED (failures=2)

# with the fix
$ python3 tests/skills/worker-pool/pool-bindings-roster.test.py
Ran 57 tests -- OK

$ bash scripts/review-checks.sh --diff <this diff>
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

I ran the other worker-pool test files locally, and they pass except spawn-worker-launcher.test.py. It fails the same way on 964af99 without this change (ValueError: '--runtime' is not in list), so it isn't caused by this commit.

— sutando-qingyun-air

qingyun-wu and others added 2 commits September 25, 2026 00:12
…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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

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 start-cli.sh to skills/worker-pool/scripts/launch-worker-session.sh. This PR's test fakes still matched argv[1].endswith("start-cli.sh"), so after main was merged they never saw a launch. The fakes now match Path(spawn_worker.LAUNCHER).name.

# 271a68a6 merged with origin/main, before the test fix
$ python3 tests/skills/worker-pool/spawn-publishes-the-handler.test.py
FAIL: test_it_is_published_before_the_session_starts
$ python3 tests/skills/worker-pool/handler-publish-failure-is-contained.test.py
FAIL: test_the_second_worker_is_still_recovered -- 0 != 1 : the surviving worker was never launched

# bed7eb3d
$ for t in tests/skills/worker-pool/*.test.py; do python3 "$t" >/dev/null 2>&1 || echo "FAIL $t"; done
(no output: all pass, including spawn-worker-launcher.test.py, which failed before main was merged)
$ bash scripts/review-checks.sh --diff <diff>
review-checks: PASS

The third failure, tests/sparrow-result-guard.test.py, failed its HERMETIC check in CI (group 5/5). It passes locally, and this PR does not touch sparrow. If it fails again I'll look into it separately.

— sutando-qingyun-air

@keweichen
keweichen dismissed their stale review September 25, 2026 20:36

Original P2 findings were fixed in 964af99 and remain fixed at bed7eb3. Dismissing this stale verdict so it no longer blocks on retired findings. This is not an approval: the head changed after my 964af99 COMMENT, and REVIEW.md criterion 15’s real post-restart round trip remains outstanding.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. [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 is live, but pool_route_handler.py:91-99 deliberately routes without consulting liveness, and docs/worker-pool-design.md:252 says a recovering worker 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-823 treats 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 recovering and the documented abandoned/retired boundary. The production tick() 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.

@sonichi

sonichi commented Sep 25, 2026

Copy link
Copy Markdown
Owner

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 (bed7eb3d3), filing a fresh CHANGES_REQUESTED for a different blocker — see the review above for the full repro and fix direction (backfill predicate excludes the recovering worker state the router itself accepts). CI/CLA are green; approval also still needs REVIEW.md criterion 15's live post-restart trace.

…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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@keweichen thanks. The P2 is fixed in 952f923.

Change. ensure_task_event_handler() now publishes when any worker is in a state the router still delivers to, ROUTABLE_STATES = ("live", "recovering", "abandoned"). Before, it only published when a worker was live. The router never asks about liveness. Per docs/worker-pool-design.md:252-254, only retired leaves the roster and sends its traffic to the core, so retired is the one state that still returns None. The existing test for that case is renamed test_only_retired_workers_is_a_noop.

Regressions in pool-roster-handler-currency.test.py:

  • test_a_recovering_worker_still_gets_a_declaration
  • test_an_abandoned_worker_still_gets_a_declaration
  • test_a_sweep_backfills_when_the_only_worker_is_recovering: goes through production tick(worker_ids=None), which is the sweep path you named.
# fix reverted (control)
FAIL: test_a_recovering_worker_still_gets_a_declaration
FAIL: test_an_abandoned_worker_still_gets_a_declaration
FAIL: test_a_sweep_backfills_when_the_only_worker_is_recovering
FAILED (failures=3)
# with fix
Ran 10 tests ... OK
# all 31 tests/skills/worker-pool/*.test.py files: 0 failures
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. [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 for live, recovering, or abandoned. The real classifier instead asks only whether the target ID is present in workers; it deliberately does not read state (pool_route_handler.py:91-99). retired is an accepted roster state, and compile_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 via load_roster(...) or {}. Exercising the production watcher after sup.tick() showed the consequence:

    tick_config_exists=False
    tick_decisions={}
    tick_routing_alarm=None
    watcher_emitted_to_core=True
    

    Positive control: publishing the same handler changed watcher_emitted_to_core to False, and the real handler returned terminal rc=4, preserving the required boundary. With no config, watch-tasks-stream.sh:810-812 never 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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@keweichen — addressed your 00:08Z review at 6318dbe.

Change: backfill and classification now share one admission rule. pool_roster.routing_roster() returns None when the roster is absent (no pool), the roster when present, and raises RosterError when unreadable. pool_route_handler.classify() and ensure_task_event_handler() both read through it, and ROUTABLE_STATES is gone. So the declaration exists exactly when the router would read a pool: retired and unknown-state rosters get one (the router still routes their bindings), an unreadable roster gets one (so the watcher invokes the router, which fails closed with MUST_HANDLE), and an absent roster stays the no-pool case. I didn't change the retired routing semantics; they now just agree with the backfill.

Regression: BackfillAgreesWithTheRouter.test_every_roster_the_router_can_meet runs the production sup.tick() sweep, then classify() on a bound task, for each of live, recovering, abandoned, retired, unknown-state, unreadable and absent. It asserts that the declaration exists iff the router reads a pool. There are also direct ensure_task_event_handler cases for retired and unreadable.

$ python3 tests/skills/worker-pool/pool-roster-handler-currency.test.py   # fix reverted (skills/ only)
FAIL ... (state='retired')
FAIL ... (state='unknown-state')
FAIL ... (state='unreadable')
FAIL: test_a_retired_worker_still_gets_a_declaration
FAIL: test_an_unreadable_roster_still_gets_a_declaration
Ran 12 tests  FAILED (failures=5)

$ python3 tests/skills/worker-pool/pool-roster-handler-currency.test.py   # at 6318dbe3
Ran 12 tests  OK

$ for f in tests/skills/worker-pool/*.test.py; do python3 "$f"; done
files=31 failed=0

$ git diff | bash scripts/review-checks.sh
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. [P2] Treat a dangling roster path as unreadable, not as no pool — pool_roster.py:201-205, reached by the new shared owner at pool_roster.py:314-318.

    Path.read_text() raises FileNotFoundError both when the roster path is genuinely absent and when state/roster.json is a dangling symlink. _load_existing_roster_strict() returns None for 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=3
    

    The first line proves a lexical roster entry exists even though following it fails. With no config, tick() does not publish and watch-tasks-stream.sh:810-812 emits the task to core. Even after explicitly publishing the handler, probe rc 3 reaches lines 834-835 and still emits it to core. The required result is the unreadable branch: publish the handler and return terminal MUST_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 incorrect None result.

  2. 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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

The current implementation resolves the prior state-mismatch blocker: ensure_task_event_handler() and the router now share routing_roster() as the single admission rule, so absent remains no-pool, unreadable invokes the handler and fails closed, and every readable roster shape receives a declaration. Publication failure stays contained, dry-run supervision remains non-mutating, and all hosted checks pass.

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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Addressed the [P2] dangling-roster finding at 1a1ad9cb.

_load_existing_roster_strict() now returns None only when os.path.lexists(p) is false. A FileNotFoundError on a path that exists lexically (a dangling symlink) raises RosterError. Both routing_roster() consumers therefore publish the handler and return MUST_HANDLE.

New DanglingRosterFailsClosed in tests/skills/worker-pool/pool-roster-handler-currency.test.py goes through sup.tick() and the handler's main(--probe):

  • dangling: the declaration is written and the probe returns MUST_HANDLE.
  • genuinely absent (control): no declaration and the probe returns DECLINE.

I also added dangling to the parity loop.

$ python3 tests/skills/worker-pool/pool-roster-handler-currency.test.py
Ran 14 tests ... OK
# same test file with pool_roster.py reverted to 6318dbe3:
FAIL: test_a_dangling_roster_publishes_the_handler_and_must_handle
Ran 14 tests ... FAILED (failures=1)
$ for t in tests/skills/worker-pool/*.test.py; do python3 "$t"; done   # suites=31 failed=0
$ git diff | bash scripts/review-checks.sh   # PASS

As you predicted, the parity subtest alone passes on the old code because both consumers agree on None. The dedicated test is the one that tells the old and new behaviour apart.

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

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Current-head blocker: required CI is red because the retired ROOM_DOC_* spellings grew beyond the repository baseline (skills/room-collab/scripts/room_collab.py:51-53 and tests/room-collab-cli.test.py:277-284). The compatibility intent is documented, but the repository gate requires either using the collab spelling or updating the deliberate-alias baseline in the same commit. Please make that compatibility exception explicit to the lint (or remove the aliases) and rerun CI.

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.

@sonichi

sonichi commented Sep 27, 2026

Copy link
Copy Markdown
Owner

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:

"you just pinned this room to worker ab3a1d79...ffa4a44fe7998 via the worker picker, and the roster/advertisement updated to reflect it. But that binding doesn't actually make new messages route to the worker yet — there's a known gap between the room→worker binding and the task-event routing that delivers messages... So right now: no, I'm not that worker — I'm still the core."

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.

sonichi added a commit that referenced this pull request Sep 27, 2026
…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>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Current-head re-review at 52af9c9c26e1970366d57c148a0da289352e13ca: the merge from main resolves the retired ROOM_DOC_* baseline failure, and all hosted checks are now green. The worker-pool implementation remains sound: handler publication precedes spawn/resume exposure, supervision backfills only on persistent sweeps, binding republishes before becoming durable, unreadable and dangling rosters fail closed, and publication failures stay contained to the affected recovery.

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 REVIEW.md criterion 15 requires the already-requested live post-restart witness for this delivery/startup path: recovery or resume, then a bound-room task reaching that worker, then the result returning. The author has explicitly identified this as outstanding; no additional code change is requested here.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Sep 27, 2026

Copy link
Copy Markdown
Owner

@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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@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

sonichi added a commit that referenced this pull request Sep 27, 2026
…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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi

sonichi commented Sep 30, 2026

Copy link
Copy Markdown
Owner

@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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

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.

@qingyun-wu qingyun-wu closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants