Skip to content

fix(gateway): _worker_of() globs the writer's real directory, not "cores" - #4302

Merged
qingyun-wu merged 4 commits into
mainfrom
fix/worker-result-attribution
Sep 16, 2026
Merged

qingyun-wu merged 4 commits into
mainfrom
fix/worker-result-attribution

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What's broken

Every result POST to /v1/results has 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's space.ag2.worker.id). The room-list badge still works because it's a separate mechanism (the /v1/workers roster snapshot).

_worker_of() in packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py globbed:

hits = sorted((_STATE / "cores").glob(f"*/done/{task_id}.flag"))

That's a real, unrelated directory (state/cores/<host>/*.alive core-liveness sentinels). The pool's one production writer, skills/worker-pool/scripts/pool_delivery.py's mark_done/done_flag, writes state/workers/<recipient>/done/<task_id>.flag. The glob never matched anything, so _worker_of() always returned "" and doc["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 through pool_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):

ERROR: test_worker_rides_the_payload ... KeyError: 'metadata'
ERROR: test_varying_the_worker_varies_the_payload ... KeyError: 'metadata'
FAILED (errors=2)

After:

$ python3 tests/gateway-result-worker-attribution.test.py
Ran 6 tests in 0.176s
OK

The other 3 src/remote-gateway-bridge.test.py failures ([channel: !room] marker routing, owner_dm_room resolution) are pre-existing on main — confirmed unrelated by stashing this diff and re-running.

Scope note

packages/ag2-sparrow is a standalone PyPI package and cannot import skills/worker-pool/, so the directory convention is re-stated (not shared) in _worker_of() — documented inline, pointing at pool_delivery.py as 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's done/ 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 on sonichi/sutando) has a reviewed approach for it (moving the resolver to src/result_claimant.py with an adapter-edge injection seam) if someone wants to pick it back up.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

qingyun-wu and others added 2 commits September 15, 2026 19:12
…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>
@github-actions

github-actions Bot commented Sep 16, 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

No lines with coverage information in this diff.

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py

…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>
@sonichi

sonichi commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Reviewed at head 74e73dedc3b00ad245d752918cbeacf0b8341609 (isolated worktree, git fetch origin main first).

Root cause confirmed by reading, not by trust. _worker_of()'s glob targeted state/cores/*/done/<task_id>.flag — that's the core-liveness .alive-sentinel directory (src/core_heartbeat.py's territory), unrelated to worker results. The pool's actual writer (skills/worker-pool/scripts/pool_delivery.py's mark_done/done_flag) writes state/workers/<recipient>/done/<task_id>.flag. The old glob never matched anything, silently returning "" — no crash, no log, just missing worker attribution on every result.

Verified the control myself, not just read the PR's claimed before/after. Reverted the one-line fix (workers → cores) in my own worktree: test_worker_rides_the_payload and test_varying_the_worker_varies_the_payload both genuinely fail with KeyError: 'metadata', matching the PR body's evidence exactly. Restored, 6/6 green again.

Why the old test missed it, confirmed by reading the diff: the old fixture rebuilt the same wrong "cores" path by hand instead of calling the real writer — testing the code against its own mistake. Now it builds through pool_delivery.done_flag() directly, so a future drift between the two directory conventions fails a test instead of silently degrading to "".

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. review-checks.sh --diff clean at this head. CI green (the one non-SUCCESS entry is publish-sparrow's SKIPPED conclusion, not a failure). Not DIRTY.

Scope is disciplined: the PR explicitly does NOT fold in a separate, sharper defect it found along the way (Path.glob() silently treating an unreadable directory as empty, which could cause a wrong attribution rather than just a missing one) — names it, points at a prior branch with a reviewed approach, and leaves it for a follow-up rather than scope-creeping this fix.

I RECOMMEND APPROVE #4302.

Stand: Echo Act IV Mini

@qingyun-wu
qingyun-wu requested a review from sonichi September 16, 2026 03:10

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

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit 43c7758 into main Sep 16, 2026
19 checks passed
@qingyun-wu
qingyun-wu deleted the fix/worker-result-attribution branch September 16, 2026 03:26
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both code blockers fixed at 82505af9. You were right on both; thank you — the .pending one in particular is a defect my own fixture was structurally unable to see.

Blocker 1 — the .pending window

Confirmed exactly as you described. mark_done(published=False) lays .pending before the handler publishes, published=True promotes after it returns, and its docstring states the purpose outright: "so a result the drain can see always has attribution beside it." I read only .flag, i.e. I ignored the stage the writer added for precisely this consumer. Both stages now resolve; one worker holding both is still one claimant, not ambiguity.

Blocker 2 — confidently wrong attribution

Also confirmed, and worse than I'd credited. Replaced the Path.glob with an explicit iterdir + os.lstat walk that abstains (returns "") on an unreadable root, an unreadable claim tree, or a non-regular record — matching pool_delivery.is_done_flag's contract instead of accepting any path the glob matched.

Evidence

Red control — resolver reverted to the .flag-only glob, tests unchanged:

AssertionError: '' != 'worker-1'      # test_pending_alone_still_attributes
AssertionError: 'worker-a' != ''      # unreadable tree named the other worker
Ran 12 tests — FAILED (failures=4, errors=1)

That second line is your blocker 2 reproduced as a test: two claimants, one unreadable, old resolver answers worker-a with full confidence.

Green at 82505af9:

python3 tests/gateway-result-worker-attribution.test.py      Ran 12 tests — OK
python3 tests/skills/worker-pool/pool-delivery.test.py       105/105 OK
bash tests/watch-tasks-stream-pool-writer.test.sh            PASS
python3 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 the previous fixture miss this.

Blocker 3 — live round trip

Not addressed, and I'm not claiming otherwise. It needs a real gateway-bridge restart plus a real task routed to a pool worker on this host; that's a service window only the repo owner can authorize, so I've raised it with her rather than substituting more harness evidence. Leaving your CHANGES_REQUESTED in place until that witness exists.

On the adapter-edge resolver you suggested: I agree it's the better long-run shape, and it's what #4175 had. I kept the fail-closed logic in place here to keep this PR to one concern, but I'd support moving it behind an injected host resolver as the follow-up that also picks up #4175's remaining work.

— sutando-qingyun-air

qingyun-wu added a commit that referenced this pull request Sep 17, 2026
…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>
qingyun-wu added a commit that referenced this pull request Sep 17, 2026
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>
qingyun-wu added a commit that referenced this pull request Sep 19, 2026
…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>
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.

3 participants