Repository navigation
fix(gateway): _worker_of() globs the writer's real directory, not "cores" - #4302
Conversation
…res" Every result POST to /v1/results has silently carried no worker attribution: _worker_of() globbed state/cores/*/done/<task_id>.flag, but the pool's one production writer (pool_delivery.py's mark_done/done_flag) writes state/workers/<recipient>/done/<task_id>.flag. "cores" is a real, separate directory (per-host core-liveness .alive sentinels), so the glob never found anything and _worker_of() always returned "". Downstream: ag2space-backend's broker only stamps a message event's space.ag2.worker.id when the result's metadata.worker_id is present (services/agent-message-broker/broker/outbound.py), and cinny-webclient's message timeline (worker corner decorator, colored sender name, content stripe — shipped 2026-08-31) renders entirely off that per-message stamp. With worker_id always empty, no message ever carried it, so a worker's replies were visually indistinguishable from core's in any room timeline — even though the room-list badge (a separate mechanism, the /v1/workers roster snapshot) has worked correctly the whole time. The existing test suite for this function didn't catch it because its _flag() fixture reimplemented the same wrong "cores" path instead of calling the real writer, so it validated the code against itself. Now the fixture builds through pool_delivery.done_flag() directly, so a future drift between the two directory conventions fails a test instead of silently returning "". Before (on the unmodified test, rebuilt to use the real writer's path): ERROR: test_worker_rides_the_payload ... KeyError: 'metadata' ERROR: test_varying_the_worker_varies_the_payload ... KeyError: 'metadata' FAILED (errors=2) After: Ran 6 tests in 0.176s OK packages/ag2-sparrow is a standalone PyPI package and cannot import skills/worker-pool/, so the path convention is re-stated (not shared) in _worker_of() — documented inline, pointing at pool_delivery.py as the one writer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI's prose-cap check flagged setUp()'s and _flag()'s comment blocks at 4 lines each; the narrative belongs in the PR body, not the code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff.
|
…s docstring tests/core-never-names-the-worker-pool-skill.test.py enforces docs/architecture-boundaries.md's "Optional adapter capabilities" rule: a core/package file may use an injected script path but must not locate a concrete optional skill by name. My earlier docstring literally spelled skills/worker-pool/scripts/pool_delivery.py — exactly the anti-pattern the test guards against, even though it was prose, not code. Reworded to describe the contract without naming the skill; the concrete path stays in the (exempt) test file, which already builds its fixtures through the real writer's own function. Confirmed: python3 tests/core-never-names-the-worker-pool-skill.test.py -> ok python3 tests/gateway-result-worker-attribution.test.py -> 6/6 OK Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reviewed at head Root cause confirmed by reading, not by trust. Verified the control myself, not just read the PR's claimed before/after. Reverted the one-line fix ( Why the old test missed it, confirmed by reading the diff: the old fixture rebuilt the same wrong Three commits total — the core fix, then two responsive follow-ups (comment-cap trim, removing a hardcoded skill path from the docstring) that read like the author addressing this repo's own automated review gates. Scope is disciplined: the PR explicitly does NOT fold in a separate, sharper defect it found along the way ( I RECOMMEND APPROVE #4302. Stand: Echo Act IV Mini |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on exact head 74e73dedc3b00ad245d752918cbeacf0b8341609. This is not ready to merge.
The defect on the parent is real: local origin/main (59c54d291d0203dbfe6c4247161fef06abb68e95, also the merge-base; this is not a stack) scans state/cores, while the production writer uses state/workers. Applying the parent decision rule to the corrected fixture produced the claimed discriminating result:
Ran 2 tests
FAILED (errors=2: KeyError: 'metadata')
Blocker — the changed reader misses the writer’s live .pending stage. src/watch-tasks-stream.sh:56-68 writes pending, runs the handler—which publishes the result—and promotes to done only after the handler returns. skills/worker-pool/scripts/pool_delivery.py:143-164 explicitly defines that ordering. But packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:3941 scans only *.flag.
A deterministic probe through pool_delivery.mark_done() and the shipped _deliver_result_payload() showed:
pending=task-pending-window0001.pending done_exists=False
payload_during_pending={'id': 'broker-task-pending-window0001', 'body': 'ready result'}
payload_after_promotion={'id': 'broker-task-pending-window0001', 'body': 'ready result', 'metadata': {'worker_id': 'worker-1'}}
Thus the outbound scanner can permanently POST a worker result without attribution between result publication and flag promotion—the same user-visible failure this PR claims to eliminate. The new fixture misses this because tests/gateway-result-worker-attribution.test.py:76-78 calls only the path builder and manually writes a .flag; it does not exercise mark_done(..., published=False) or the actual ordering. Unblock by resolving both writer stages when a ready result is being delivered, with a regression driven through the production writer/order.
Blocker — the activated glob can confidently stamp the wrong worker instead of failing closed. The writer contract at pool_delivery.py:113-127 accepts only regular, non-symlink records, while _worker_of() treats any matching path as evidence and Path.glob() silently skipped an unreadable claimant subtree in the exercised case:
unreadable_two_claimants='worker-b'
directory: writer_accepts=False resolver='worker-directory'
symlink: writer_accepts=False resolver='worker-symlink'
Worst-case disruption is therefore not merely a missing stripe: a reply can carry another worker’s identity with full confidence. Mitigate by using the writer’s record predicate and failing closed on unreadable, malformed, or ambiguous state. A host-side resolver injected at the adapter edge would also preserve the standalone package boundary; an equivalent fail-closed design is fine.
Blocker — required live-path evidence is absent. This changes the gateway result-delivery path, so REVIEW.md requires a real post-restart round trip before approval. The PR body and supplied discussion contain only harness evidence. After fixing the resolver, restart the real gateway bridge, route a real task to a pool worker, and show that the delivered event/client view carries that worker’s identity; include the restart impact and replay behavior.
Local checks at this head:
python3 tests/gateway-result-worker-attribution.test.py— 6/6 passed.python3 tests/skills/worker-pool/pool-delivery.test.py— 105/105 passed.bash tests/watch-tasks-stream-pool-writer.test.sh— passed.- Architecture guard,
git diff --check, and repository review checks — passed. - Added-line host-path scan was clean; its positive control correctly rejected
/Users/reviewer/private. tests/sparrow-integration-e2e.test.pycould not bind its localhost server in this sandbox (PermissionError: Operation not permitted), so I do not claim that suite passed; it is a mock and would not replace the required live witness anyway.
Reviewed by Kewei's Codex automation.
|
@cla-assistant check |
|
Both code blockers fixed at Blocker 1 — the
|
…ion residue (#4359) * fix(watcher): bracket the agent path with the worker record mark_done had exactly one reachable caller — record_worker_done() inside the --handler-runner branch — so a worker AGENT that reads its delivery, does the work and publishes a result never produced a done-flag. _worker_of() then read an empty claim tree and returned "", no metadata.worker_id left the host, and the client had no space.ag2.worker to render. The reader fixes (#4302, #4306) were correct and starved. dispatch_task now lays the pending claim before the work becomes visible, and promotes it once it observes the answer — which on the agent path is the only completion signal, since no handler runs to settle the record. Both stages use the functions that already exist. Inert off a worker: record_worker_done returns early unless the spawner injected SUTANDO_INSTANCE_ID and SUTANDO_POOL_DELIVERY_SCRIPT, so the core calls it and nothing happens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(attribution): read the worker from assignment truth, not completion residue `_worker_of()` infers a result's author from done-flag residue, which exists only if the worker got far enough to lay it and survives only until something reaps it. Attribution that depends on the completion path cannot describe a result whose completion path was interrupted — and those are exactly the results attribution needs to be right about. Provenance is now fixed where the routing decision is made. `pool_router.route` records `task_id -> worker_id` at assignment (O_EXCL, first write wins on a fan-out, the core never recorded); the bridge reads that record and stamps `metadata.worker_id` from it. Workers' result-writing contract is unchanged: no publish helper, no results/ watcher, no self-asserted identity. Completion residue stays as the transition fallback for tasks routed before the record existed, and firing it logs an anomaly — a result whose author was never recorded at routing time is the case the store exists to surface. Both readers fail closed: unreadable, non-regular, or outside the 32-hex instance-id grammar yields "" rather than a guess, because a wrong worker id labels a reply with another worker's identity. Control — reverting the single call site to `_worker_of` fails the new suite with 2 failures + 1 error, on exactly the three assignment-truth cases: WITHOUT: FAILED (failures=2, errors=1) WITH: Ran 10 tests — OK Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): split the multi-import line ruff rejects (E401) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(attribution): cover the fail-closed arms the diff-coverage gate caught The gate was right to fail at 91%: the uncovered lines were the two OSError arms of _assigned_worker and worker_for_task's empty-id guard — i.e. the fail-closed behaviour the docstrings assert and nothing exercised. A claim about refusing bad input is worth exactly as much as its test. Covered by raising from the syscall rather than by chmod, so the branch is reached deterministically on any runner: a PermissionError from lstat can only reach "" through `except OSError`, since FileNotFoundError is caught above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(attribution): record on delivery under the lock, atomically; retire the superseded hunk Three findings from review, all reproduced before fixing: 1. Recording ran BEFORE deliver_one(), so a `no-payload` or `already` refusal still wrote an attribution. A later real delivery to a different worker then inherited the stale id and the bridge stamped a reply with a worker that never ran — the exact misattribution the reader's fail-closed rule exists to prevent. Recording now happens inside deliver_one's arbitration lock, after the sentinel is created: attribution and delivery are one fact. 2. record() was O_EXCL-then-write, which is not atomic. A crash between the two left an empty record that record() refuses forever and worker_for_task rejects, making the task permanently unattributable AND unrecordable. Now the content is written to a temp file and published by link(), so the name never exists without its content. 3. The PR still carried the settle_worker_record hunk from its pre-rescope scope, which the reviewer had blocked as unreachable. The assignment-time mechanism removes the dependency on that path entirely, so the hunk and its test are retired rather than left to imply a second, unreachable mechanism. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's "Review checks (REVIEW.md)" step failed on a 4-line comment block. The constraint stays in the code — a ready result is delivered while only .pending exists, so reading .flag alone loses that window — and the attribution to keweichen/#4302 moves to the PR body, which is where checkable history belongs. Note the separator line counts toward the block: dropping the narrative to two prose lines still read as 3 with `# --- … ---` above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…4306) * fix(gateway): _worker_of() reads both writer stages and fails closed Follow-up to #4302, which merged at 03:26:38Z two minutes after @keweichen filed CHANGES_REQUESTED on it. His two code findings were correct and are still live on main; this PR closes them. BLOCKER 1 — the .pending window. pool_delivery.mark_done() lays `<task>.pending` BEFORE the handler publishes the result and promotes it to `<task>.flag` only after the handler returns; its own docstring states that stage exists "so a result the drain can see always has attribution beside it". _worker_of() reads only `.flag`, so a result delivered inside that window still goes out unattributed — the exact failure #4302 set out to remove. Both stages now count, and one worker holding both is one claimant, not ambiguity. BLOCKER 2 — confidently wrong attribution. Path.glob() reports an unreadable subtree as absent, so with two claimants and one unreadable the resolver handed the answer to the other one; and it accepted any matching path where the writer's own predicate (pool_delivery.is_done_flag) accepts only a regular, non-symlink file. Replaced the glob with an explicit iterdir + os.lstat walk that abstains — returns "" — on an unreadable root, an unreadable claim tree, or a malformed record. A wrong worker id is worse than none: it labels a reply with another worker's identity. Evidence, at this head (branched from main 43c7758): RED control — resolver reverted to main's .flag-only glob, tests unchanged: AssertionError: '' != 'worker-1' (pending window) AssertionError: 'worker-a' != '' (unreadable tree names the other) Ran 12 tests — FAILED (failures=4, errors=1) GREEN — this commit: tests/gateway-result-worker-attribution.test.py Ran 12 — OK tests/skills/worker-pool/pool-delivery.test.py 105/105 OK tests/core-never-names-the-worker-pool-skill.test.py ok The new regressions drive pool_delivery.mark_done() in the real publish-then-promote order rather than hand-writing a `.flag`, which is the gap that let #4302's fixture miss this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): trim the comment block review-checks rejects (cap 2) CI's "Review checks (REVIEW.md)" step failed on a 4-line comment block. The constraint stays in the code — a ready result is delivered while only .pending exists, so reading .flag alone loses that window — and the attribution to keweichen/#4302 moves to the PR body, which is where checkable history belongs. Note the separator line counts toward the block: dropping the narrative to two prose lines still read as 3 with `# --- … ---` above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(attribution): probe .pending before .flag so promotion cannot fall between keweichen's blocker on #4306, reproduced before fixing. The writer publishes `.flag` and only then unlinks `.pending` (pool_delivery.mark_done), so at every instant at least one name exists. Probing `.flag` first could observe NEITHER when promotion landed between the two lstats — an unattributed result while the writer maintained continuous evidence throughout. Pending-first cannot hit that window: a present `.pending` resolves immediately, and an absent one means `.flag` is already there. The regression drives the REAL writer between the probes rather than faking the filesystem: the first lstat returns, mark_done(published=True) promotes, the second lstat runs. Control against the old order reproduces the reviewer's own measurement exactly: flag-first: FAILED — lost attribution across promotion: flag_exists=True pending_exists=False pending-first: Ran 13 tests — OK Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(attribution): cover the claim root's unreadable-OSError branch The branch that distinguishes NO READING from "nobody claimed it" was the only uncovered part of the diff (89.5%, missing 4022-4023), so the guard carrying that distinction had nothing asserting it. The test carries its own control: it first asserts the same fixture resolves to worker-a unfaulted, so the abstention cannot pass for a fixture that never worked. Removing the guard errors this test alone with the propagated PermissionError; restoring it returns the suite to 14 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gateway): one writer-owned contract validates recipients and records _worker_of() re-derived the completion-record layout and substituted lstat() for the writer's open-based predicate, so it accepted state the pool's own mark_done() refuses: a recipient name that raises ValueError, and a record whose predicate raises PermissionError, both resolved to structured worker metadata. It also treated a stray non-directory entry beside the recipient folders as an unreadable claimant, suppressing valid attribution. src/pool_record.py now owns the recipient grammar, the workers-root layout, the two stage names and the record predicate. skills/worker-pool's pool_delivery.py binds it as the writer; the standalone gateway package bundles it (tools/sync_from_src.py) and binds it as the reader, so neither side keeps a private regex or open predicate. Entries proven not to be recipients are skipped rather than failing the whole lookup closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(pool): pin the single owner of the completion-record contract Three controls on the reader, each of which resolves a worker on the parent resolver: a stray non-directory entry beside the recipient folders (it read as an unreadable claimant and suppressed valid attribution), a recipient name the writer refuses with ValueError, and a chmod-000 record the writer's predicate refuses with PermissionError. The guard pins delegation on both sides: the writer's grammar, layout and predicate are the shared objects, and the resolver declares no lstat, S_ISREG, recipient regex or "workers" literal of its own. The promotion-race case now drives the real writer through the shared probe rather than os.lstat, which the resolver no longer calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(src-map): regenerate for src/pool_record.py Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(pool): cover the contract's refusal branches The unknown-stage ValueError, the absent/malformed record states, and the entry whose type cannot be read — the last stays in the recipient list because a failed is_dir() does not prove it is not one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pool): classify a record without an open that can wait `record_state()` opened an untrusted record with a blocking `O_RDONLY` before `fstat()` could type it. A FIFO at a record name has no writer, so that open waits forever and `_worker_of()` stalls the gateway's sole outbound worker instead of failing closed on non-regular state. The open is now non-blocking, so `fstat()` types a FIFO or device immediately and the existing tri-state answers MALFORMED for it. The vendored copy is regenerated by tools/sync_from_src.py, not hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pool): a recipient-named symlink is malformed state for writer and reader alike The writer followed a symlinked recipient directory and published the record under the target's folder; the reader skipped the alias, saw the target as the unique claimant, and named another worker on the reply. The shared contract now defines the alias: `require_own_dir` refuses a symlink at the recipient or its `done/` before the writer publishes, and `iter_recipients` raises `RecipientAliasError` (an OSError) so `_worker_of` abstains instead of resolving it. Vendored copy re-synced; regressions on the contract, the writer, and the delivered payload (the reviewer's A→B repro). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(gateway): log the worker a delivered result is attributed to The delivery payload carries metadata.worker_id, but nothing on the host records it, so a live witness of attribution had nothing to read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: qingyun-wu <qingyun@ag2.ai>
What's broken
Every result POST to
/v1/resultshas silently carried no worker attribution, so a pool worker's replies are indistinguishable from core's anywhere a client only has the per-message signal (cinny-webclient's timeline decorator, colored sender name, content stripe — shipped 2026-08-31, keyed on the Matrix event'sspace.ag2.worker.id). The room-list badge still works because it's a separate mechanism (the/v1/workersroster snapshot)._worker_of()inpackages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.pyglobbed:That's a real, unrelated directory (
state/cores/<host>/*.alivecore-liveness sentinels). The pool's one production writer,skills/worker-pool/scripts/pool_delivery.py'smark_done/done_flag, writesstate/workers/<recipient>/done/<task_id>.flag. The glob never matched anything, so_worker_of()always returned""anddoc["metadata"] = {"worker_id": worker}was never set.Why the existing test didn't catch it
tests/gateway-result-worker-attribution.test.py's_flag()fixture reimplemented the same wrong"cores"path instead of calling the real writer, so it validated the code against itself. Fixed the fixture to build throughpool_delivery.done_flag()directly — a future drift between the two directory conventions now fails a test instead of silently returning"".Evidence
Before (test rebuilt to use the real writer's path, against the unmodified
_worker_of):After:
The other 3
src/remote-gateway-bridge.test.pyfailures ([channel: !room]marker routing,owner_dm_roomresolution) are pre-existing onmain— confirmed unrelated by stashing this diff and re-running.Scope note
packages/ag2-sparrowis a standalone PyPI package and cannot importskills/worker-pool/, so the directory convention is re-stated (not shared) in_worker_of()— documented inline, pointing atpool_delivery.pyas the one writer.This does not include a fix for a sharper, related defect found during a prior attempt at this same bug (#4175, closed 2026-09-12 in a PR-triage sweep, not for being wrong — it was superseded by the worker-pool consolidation and never re-opened as its own PR):
Path.glob()silently treats an unreadable directory as empty rather than raising, so if one worker'sdone/subdirectory is unreadable while another's is readable, the ambiguity guard (len(hits) == 1) can't see it and this function would confidently return the wrong worker instead of abstaining. That's a real, separate correctness gap (wrong attribution, not just missing) — flagging it rather than folding it in here to keep this PR to one concern; #4175's branch (fix/result-stamp-finds-pool-worker, preserved onsonichi/sutando) has a reviewed approach for it (moving the resolver tosrc/result_claimant.pywith an adapter-edge injection seam) if someone wants to pick it back up.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com