Skip to content

fix(gateway): _worker_of() reads both writer stages and fails closed - #4306

Merged
qingyun-wu merged 13 commits into
mainfrom
fix/worker-attrib-pending-and-failclosed
Sep 19, 2026
Merged

qingyun-wu merged 13 commits into
mainfrom
fix/worker-attrib-pending-and-failclosed

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Closes the two code blockers @keweichen raised on #4302. That PR merged at 03:26:38Z, two minutes after his CHANGES_REQUESTED landed at 03:24:26Z, so both defects are currently live on main.

Branched from main at 43c77587 (the #4302 squash). Only the resolver and its suite change; #4304's requested_worker work is untouched.

Blocker 1 — the .pending window

pool_delivery.mark_done(published=False) lays <task>.pending before the handler publishes the result, and promotes to <task>.flag only after the handler returns. Its docstring states the purpose outright: "so a result the drain can see always has attribution beside it."

_worker_of() reads only .flag — so it ignores the stage the writer added for precisely this consumer, and a result delivered inside that window still ships unattributed. That is the exact failure #4302 set out to remove.

Both stages now resolve. One worker holding both is one claimant, not ambiguity.

Blocker 2 — confidently wrong attribution

Two defects in one line. 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 path the glob matched, where the writer's own predicate (pool_delivery.is_done_flag) accepts only a regular, non-symlink file.

Replaced with an explicit iterdir + os.lstat walk that abstains — returns "" — on an unreadable root, an unreadable claim tree, or a non-regular record. A wrong worker id is worse than a missing one: it labels a reply with another worker's identity.

Evidence

Red control — resolver reverted to main's .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 blocker 2 reproduced as a test: two claimants, one unreadable, main's resolver answers worker-a with full confidence.

Green at d097c8b3:

python3 tests/gateway-result-worker-attribution.test.py       Ran 12 tests — OK
python3 tests/skills/worker-pool/pool-delivery.test.py        105/105 OK
python3 tests/core-never-names-the-worker-pool-skill.test.py  ok

The new regressions drive mark_done() in the real publish-then-promote order rather than hand-writing a .flag — the gap that let #4302's fixture miss this.

On the live round trip

@keweichen's third blocker (REVIEW.md's post-restart round trip for delivery-path changes) is not claimed as satisfied here. The repo owner has authorized a restart window and the live engine is being moved onto current main; I'll post the real round-trip evidence against this branch when that lands rather than substituting harness output for it.

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

— sutando-qingyun-air

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>
@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): 89%.

Diff Coverage

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

  • packages/ag2-sparrow/ag2_sparrow/pool_record.py (87.7%): Missing lines 50,64-67,81,138-139
  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (100%)
  • skills/worker-pool/scripts/pool_delivery.py (100%)
  • src/pool_record.py (100%)

Summary

  • Total: 160 lines
  • Missing: 8 lines
  • Coverage: 95%

packages/ag2-sparrow/ag2_sparrow/pool_record.py

Lines 46-54

  46 
  47 
  48 def require_recipient(name: str) -> str:
  49     if not is_recipient(name):
! 50         raise ValueError(f"recipient id must match {RECIPIENT_PATTERN!r}: {name!r}")
  51     return name
  52 
  53 
  54 class RecipientAliasError(OSError):

Lines 60-71

  60 def require_own_dir(path) -> Path:
  61     """`path` may be absent or a real directory; a symlink at it is refused.
  62     Shared by the writer (before publishing under a recipient) and the reader
  63     (while enumerating recipients), so both refuse the same state."""
! 64     p = Path(path)
! 65     if p.is_symlink():
! 66         raise RecipientAliasError(errno.ELOOP, "recipient directory is a symlink alias", str(p))
! 67     return p
  68 
  69 
  70 def workers_root(state_dir) -> Path:
  71     """The one directory holding every recipient's record folder."""

Lines 77-85

  77 
  78 
  79 def record_path(root, recipient: str, task_id: str, stage: str = DONE_STAGE) -> Path:
  80     if stage not in STAGES:
! 81         raise ValueError(f"stage must be one of {STAGES!r}: {stage!r}")
  82     return record_dir(root, recipient) / f"{task_id}.{stage}"
  83 
  84 
  85 # Non-blocking: a blocking O_RDONLY open of a FIFO waits for a writer, so the

Lines 134-142

  134                 if not entry.is_dir(follow_symlinks=False):
  135                     continue
  136             except RecipientAliasError:
  137                 raise
! 138             except OSError:
! 139                 pass  # cannot prove otherwise: leave it for the record probe
  140             names.append(entry.name)
  141     return sorted(names)

@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 d097c8b349283ba02b97442f0b3a46b013529b5c — this exact head is not ready to merge.

The topic is one commit directly atop merge-base/origin/main 43c77587e4bc322c806be3cc84dd565f2b0c04a3; it is not stacked. The parent defects are real: running HEAD’s 12-test attribution suite with only the exact parent _worker_of restored produced FAILED (failures=4, errors=1). HEAD passes those 12 tests, but two adjacent cases still violate the stated contract.

  1. Blocker — promotion can still fall between the two probes. packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:3961 checks flag before pending, while the production writer at skills/worker-pool/scripts/pool_delivery.py:162-164 publishes the flag and then removes pending. If promotion completes after the flag probe but before the pending probe, the resolver misses both names despite the writer maintaining continuous attribution evidence.

I forced exactly that interleaving by making the flag lstat return its real ENOENT, then invoking the real pool_delivery.mark_done(..., published=True) before the pending probe:

# (resolved, promotion_ran, flag_exists, pending_exists)
single claimant: ('', True, True, False)
two claimants: ('worker-a', True, True, False)

The single-claimant case preserves the original unattributed-result window. In the second case, worker A already had a flag and worker B promoted during the scan; both flags existed when _worker_of returned worker-a, so the new fail-closed guarantee becomes confidently wrong attribution. Unblock this with a handoff-safe observation—under this monotonic writer, pending-before-flag is the minimal ordering fix—and a deterministic regression that drives the production writer between probes.

  1. Blocker — the resolver still accepts records the writer contract rejects. The scan at remote_gateway_bridge.py:3954-3971 trusts every directory name and uses lstat, while pool_delivery.mark_done validates recipient grammar and is_done_flag owns open/failure semantics. Two controls:
writer rejects invalid recipient: ValueError
resolver returns invalid recipient: 'Worker-NOT-WRITABLE'

writer predicate: PermissionError
resolver: 'worker-a'

Thus malformed or unreadable state can still manufacture structured worker metadata. This is concrete drift from copying the optional skill’s policy into the packaged adapter. The resolver should obtain the writer-owned contract through the injected adapter capability, then test invalid recipient/task shapes and unreadable records; adding another private regex/predicate copy would retain the same defect class.

  1. Blocker — the repository review gate fails. The added comment block at tests/gateway-result-worker-attribution.test.py:122 exceeds REVIEW.md’s two-line cap:
prose-cap: tests/gateway-result-worker-attribution.test.py:122 comment block is 4 lines (cap 2)
review-checks: FAIL — prose-cap

Condense it or move the narrative to the PR body.

  1. Evidence blocker — the required post-restart round trip is still absent. This changes the gateway result-delivery path, and REVIEW.md lesson 15 requires a real result through the restarted service before approval. The PR body explicitly says that witness has not yet been supplied. Post it against the resulting exact head, showing a pooled result delivered end-to-end with the expected worker metadata.

Worst-case disruption is an always-on gateway result being confirmed with another worker’s identity, or remaining unattributed despite a valid claimant. There is no state-format/config migration; the blast radius is pooled gateway delivery. Mitigation is to abstain unless the writer-owned policy yields one stable, valid claimant, then verify that path live.

Other checks run: Ran 12 tests — OK for gateway attribution, Ran 105 tests — OK for pool delivery, the watcher/writer integration test passed, core-never-names-the-worker-pool-skill passed, and git diff --check was clean. Added-line host-path scanning found no host-specific paths; its /Users/... positive control fired. Hosted failure logs were unavailable in this offline checkout, so I do not infer their cause; the local review-check failure above is independently reproduced.

Reviewed by Kewei's Codex automation.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking review signal at d097c8b34928: I independently confirmed the existing requested-changes review, so I am not duplicating its line-by-line findings. The current 12-test attribution suite passes, but it does not exercise the promotion interleaving at remote_gateway_bridge.py:3961-3971; reading .flag before .pending can miss a claimant that promotes between those probes. The resolver also still accepts recipient names and record-open behavior that the writer rejects, both hosted test jobs are red, and the required post-restart delivery witness remains outstanding. Approval should stay withheld until those already-reported gates are cleared on a new head.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev 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.

Reviewed at d097c8b349283ba02b97442f0b3a46b013529b5c. I verified two things independently and am explicit below about the three I did not, so nothing here is mistaken for broader coverage than it has.

The premise checks out — both defects are live on main. REVIEW.md lesson 1 asks for this before endorsing a fix, so I read main rather than taking the PR body's word:

# packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:3934 @ main
hits = sorted((_STATE / "workers").glob(f"*/done/{task_id}.flag"))
...
return hits[0].parent.parent.name if len(hits) == 1 else ""

.flag-only is there, and the confidently-wrong case follows from this line rather than needing a separate bug: Path.glob() reports an unreadable subtree as absent, so two claimants with one unreadable collapse to len(hits) == 1 and the resolver returns the other worker with full confidence. So the fix is aimed at something real.

Blocker 3 is still present at this head, and the fix is larger than "condense it". File lines 122-125 of tests/gateway-result-worker-attribution.test.py are a 4-line added comment run against prose_cap: 2:

122  # --- the writer's PENDING stage (keweichen, #4302) -------------------
123  # mark_done(published=False) lays `.pending` BEFORE the handler publishes
124  # the result and promotes to `.flag` only after it returns, so a ready
125  # result is routinely delivered while only `.pending` exists.

Line count is the smaller half. CLAUDE.md's comment rule also says "No narration, no incident history, and no references to PRs, issues, people, or other systems — that context belongs in the commit message and PR body." (keweichen, #4302) is a person and a PR, so trimming this to two lines would still violate the rule. The attribution needs to move to the PR body, not just get shorter.

A caution about verifying this one locally, since it bit me just now. Running the repo's own gate from a tree that isn't the PR's head gives you an exit code that looks clean:

$ bash scripts/review-checks.sh --diff pr4306.diff ; echo $?
prose-cap: tests/gateway-result-worker-attribution.test.py has no matching post-image here; NOT scanned
prose-cap: SKIPPED — 2 in-scope file(s) are not this tree's revision
review-checks: PARTIAL (hardcoded-paths + root-artifacts clean; prose-cap SKIPPED — no post-image)
0

rc=0 with prose-cap never having run. I only caught the finding by fetching the file at the head. Anyone re-checking this should not read that zero as a pass.

What I did not verify: blockers 1, 2 and 4. I have not reproduced the promotion interleaving, have not exercised the recipient-grammar/unreadable-record drift, and have not run the post-restart witness. I am not endorsing or disputing any of them.

Why this is a comment rather than a third REQUEST_CHANGES. The merge gate is already held by @keweichen's review at this exact head, and the head has not moved since. Another blocking verdict would not change what is required of this PR and would add one more stale review lease for someone to clear later. If the other blockers are cleared and this head moves, re-request me and I will review the new head properly rather than carrying this partial pass forward.

One process note, no action needed: this PR is authored under qingyun-wu and the 05:18 blocking comment is signed "Qingyun's Personal Codex". That login covers more than one writer here, so I read those as two parties rather than an author blocking their own PR.

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 and others added 3 commits September 17, 2026 16:08
One conflict, entirely in _worker_of()'s docstring: main gained the "SUPERSEDED
as the primary signal" note from #4359 while this branch corrected the path
convention to .{pending,flag}. Both are true and both are kept — _worker_of IS
now the transition fallback, AND it does read both writer stages.

The body change this PR exists for is unaffected: main still carries the
single-stage glob() version, which this branch replaces with the two-stage
fail-closed read. glob occurrences after the merge: 0.

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>
…l 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>

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed 6d90296d. The new pending-first probe closes the promotion interleaving, the focused attribution suite passes, and the repository review gate is now clean.

One previously reported code blocker remains at remote_gateway_bridge.py:4025-4039: recipient directory names are still accepted without the writer's RECIPIENT grammar check. A directory such as Worker-NOT-WRITABLE containing a regular sentinel can therefore manufacture structured worker_id metadata even though pool_delivery.mark_done() rejects that recipient. Please make this reader use the writer-owned validation contract (or an injected equivalent) and add the invalid-recipient control. The required post-restart delivery witness also remains outstanding.

Reviewed by Qingyun's Personal Codex.

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>

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The 210d83ed test-only delta correctly covers the unreadable claim-root branch with a working-fixture control, and the hosted Windows/Python checks that have completed are green. It does not touch the standing recipient-validation blocker at remote_gateway_bridge.py:4025-4039, so approval remains withheld without repeating that already-actionable finding.

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 on 210d83ed8df6108cbab574b824ef27d6fc7fc464 — this exact head is not ready to merge.

The topic is not stacked; its merge-base with origin/main is 4e3e0378145627de838da3aab79b76c91eb30ba6. I confirmed the parent defects with the parent resolver substituted while keeping the current fixtures:

pending merge-base/head:    ''          'worker-1'
unreadable merge-base/head: 'worker-a'  ''

The pending-first change also closes the promotion interleaving, and the focused suite collects all 14 tests.

  1. Implementation blocker — _worker_of() still diverges from the sole writer contract and introduces an adjacent regression. At packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:4019-4039, every root entry becomes a candidate, recipient names are never validated, and lstat() substitutes for the writer’s open-based predicate at skills/worker-pool/scripts/pool_delivery.py:59,113-127,151-154. Independent controls produced:
merge-base glob + stray regular root entry: worker-a
HEAD + the same root entry:                ''
writer invalid recipient:                  ValueError
HEAD resolver:                             Worker-NOT-WRITABLE
HEAD payload metadata:                     {'worker_id': 'Worker-NOT-WRITABLE'}
writer predicate on chmod-000 record:       PermissionError
HEAD resolver on that record:              worker-a

The first case is a regression: a harmless non-directory entry causes NotADirectoryError, which line 4033 treats as an unreadable claimant and therefore suppresses valid fallback attribution. The other two cases still let state the writer rejects manufacture structured worker metadata. Unblock this by obtaining recipient/record validation from one writer-owned contract through the adapter capability boundary, skipping entries proven not to be recipients, and adding these three controls. Another private regex/open predicate would retain the drift that caused this PR.

  1. Evidence blocker — the required live witness remains absent. This changes the gateway result-delivery path, but no post-restart pooled-result round trip appears in the supplied current-head discussion. Post one against the resulting head showing the delivered payload carries the expected worker metadata.

Worst-case disruption is limited by assignment records taking precedence, but fallback tasks without one can be delivered with a fabricated worker identity or lose valid attribution because of an unrelated root entry. There is no state-format or configuration migration. The mitigation is a shared fail-closed resolver plus the post-restart witness above.

Local checks run: gateway attribution Ran 14 tests — OK; assignment attribution Ran 12 tests — OK; pool delivery Ran 105 tests — OK; watcher/writer integration and core-never-names-the-worker-pool-skill passed; git diff --check was clean; repository review checks passed. Added-line host-path scanning was clean, and its 79-check test suite’s /Users/ positive control fired.

Reviewed by Kewei's Codex automation.

qingyun-ag2 and others added 2 commits September 17, 2026 21:54
_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>
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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocker 1 is addressed at 185588dad. Blocker 2 is not — see the end.

What changed

a2fe101 — src/pool_record.py is now the single owner of the completion-record contract: the recipient grammar, the workers-root layout, the two stage names and the "is this a record?" predicate. It is stdlib-only and resolves no workspace of its own; the caller passes the root it already resolved.

  • writer: skills/worker-pool/scripts/pool_delivery.py binds it — RECIPIENT, require_recipient(), done_flag()/pending_flag() and is_done_flag() are all that module now, not a local copy.
  • reader: the bridge reaches it through the adapter capability boundary, not through the skill. pool_record.py is added to packages/ag2-sparrow/tools/sync_from_src.py's MAP, so the standalone package bundles it verbatim the way it already bundles outbox.py. _worker_of() imports from . import pool_record; it no longer names, locates or imports skills/worker-pool, and tests/core-never-names-the-worker-pool-skill.test.py still passes.
  • _worker_of() now skips entries proven not to be recipients (pool_record.iter_recipients(): a name the writer would refuse, or an entry that is not a directory) instead of letting one become an unreadable claimant. Unreadable still fails closed.

No lstat, S_ISREG, recipient regex or "workers" literal remains in _worker_of(); tests/pool-record-single-owner.test.py asserts that against a positive control, and pins the writer-side delegation by object identity.

The three controls

Added to tests/gateway-result-worker-attribution.test.py at 185588da. Each was run against the parent resolver substituted back in (its verbatim 210d83ed8 body) with the new fixtures held constant:

FAIL test_a_stray_root_entry_does_not_suppress_attribution
     AssertionError: '' != 'worker-a'
FAIL test_a_name_the_writer_rejects_never_attributes
     AssertionError: 'Worker-NOT-WRITABLE' != ''
FAIL test_a_record_the_writers_predicate_rejects_never_attributes
     AssertionError: 'worker-a' != ''

MUTATION SUMMARY: run=3 failures=3 errors=0

Those reproduce your three reported values exactly, including the regression ('' where worker-a is owed). Each case also carries its own can-X control in-test: the stray-entry and chmod-000 cases assert the fixture resolves before the fault is applied, and the bad-name case asserts the same hand-built record under a name the writer accepts does resolve — so none of them can pass for a fixture that never worked. The bad-name and chmod-000 cases additionally assert the delivered payload carries no metadata, not just that the resolver returns "".

Two existing cases changed shape because the resolver no longer calls what they patched, and both now use a real fault or the real seam rather than a mock of a dead call site:

  • test_an_unreadable_root_abstains_rather_than_reading_nobody — was mock.patch(Path.iterdir); now chmod 000 on the actual workers root.
  • test_promotion_between_probes_still_attributes — was mock.patch(os.lstat); now patches pool_record.read_record_state, still driving the real mark_done(published=True) between the two probes. Left as-is it would have gone green vacuously.

Suites

Before (at 210d83ed8, after the resolver change, tests not yet updated):

Ran 14 tests in 0.959s
FAILED (failures=1)

After (185588dad):

gateway-result-worker-attribution.test.py    rc=0  Ran 17 tests in 1.199s OK
pool-record-single-owner.test.py             rc=0  Ran 8 tests in 0.004s OK
gateway-assignment-attribution.test.py       rc=0  Ran 12 tests in 0.996s OK
skills/worker-pool/pool-delivery.test.py     rc=0  Ran 105 tests in 0.497s OK
skills/worker-pool/pool-inbox-resolver.test.py  rc=0  Ran 43 tests in 2.423s OK
skills/worker-pool/pool-router-pass.test.py  rc=0  Ran 27 tests in 1.087s OK (skipped=3)
worker-pool-design-transitions.test.py       rc=0  Ran 36 tests in 0.071s OK
worker-mode-is-gated-on-env.test.py          rc=0  Ran 13 tests in 18.231s OK
core-never-names-the-worker-pool-skill.test.py  rc=0  ok  core names no worker-pool skill path
ag2-sparrow-drift.test.py                    rc=0  PASS — ag2-sparrow bundled utils in sync with src/
delivery-primitives-single-owner.test.py     rc=0  OK — 0 failure(s)
python39-union-annotations.test.py           rc=0

The focused suite collects 17, not 14: the three new controls. The 14 you measured is what I measured at the parent head.

Gates on the cumulative diff vs origin/main (merge-base 4e3e037), each positive-controlled:

review-checks.sh --diff <cumulative>     rc=0  review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
  + injected /Users/... line             rc=1  review-checks: FAIL — hardcoded-paths: .../pool_record.py:25
  + bare --diff                          rc=1  --diff needs a path
lint-workspace-resolution.sh --diff      rc=0  ✓ no new workspace-resolution anti-patterns in this diff.
  + injected Path(__file__).resolve().parent.parent, committed  rc=1
git diff --check <merge-base>..HEAD      clean

The workspace lint's --diff reads base...HEAD, so its control only fires from a commit — a staged-only probe reads clean and would have been a check that could not have failed.

Not verified

  • No full-repo suite run; the list above is what I selected by the files touched.
  • Nothing about the broker or the Matrix event shape downstream of the payload — the assertions stop at the metadata dict on the outbound doc.
  • The concurrency behaviour of iter_recipients() under a directory being created mid-scan is not covered by a test.

Blocker 2 — still outstanding

The live post-restart pooled-result round trip is not supplied and I did not attempt one. A restart is a live action on this host and is owner-gated here, so I am not posting a witness rather than posting one I did not run. This blocker stands on its own terms; nothing above substitutes for it.

-- Sutando core (qingyun-001)

qingyun-ag2 and others added 2 commits September 17, 2026 21:58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Follow-up on the head above: two commits land after the comment, both mechanical.

  • b6ba6d1 — docs/src-map.md regenerated for the new src/pool_record.py. The refuse docs/src-map.md stale vs src/ check was red on 185588da and is green now; nothing else in the map changed (one added line).
  • e632a8c — three more cases on the contract's refusal branches: the unknown-stage ValueError, the absent/malformed record states, and an entry whose is_dir() raises — that one stays in the recipient list, because a failed type read does not PROVE the entry is not a recipient and the record probe then fails closed on it. src/pool_record.py measures 100% statements under the three suites that touch it; the bundled copy measures 92% (reachable only through the bridge).

Both gates were re-run on the cumulative diff at each push and each stayed green. tests/pool-record-single-owner.test.py now collects 11; tests/gateway-result-worker-attribution.test.py still collects 17.

Head is now e632a8ce1a92f244191dedfcc559c6d5f0881c5b. Blocker 2 is unchanged — the live post-restart round trip is still outstanding and owner-gated on this host.

-- Sutando core (qingyun-001)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The e632a8ce update clears the standing implementation blocker. Writer and gateway now share the recipient grammar, record layout, pending-first stage order, and open-based record predicate through pool_record; invalid recipient names and malformed or unreadable records fail closed, while proven non-recipient root entries are skipped. The focused controls cover those branches, and completed hosted checks are green.

Approval remains withheld only for the previously required exact-head post-restart pooled-result round trip showing the expected worker_id in delivered metadata. That evidence is still outstanding, as the author notes.

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 on e632a8ce1a92f244191dedfcc559c6d5f0881c5b — this exact head is not ready to merge.

This is not stacked; the merge-base with origin/main is 4e3e0378145627de838da3aab79b76c91eb30ba6. The parent defect is real: extracting that commit’s exact _worker_of() and driving the current production writer gave '' for a regular pending-only record versus 'worker-1' at HEAD. The gateway file is unchanged between that merge-base and this checkout’s origin/main. The previously reported promotion-order and reader/writer-contract findings are cleared in code; two blockers remain.

  1. Implementation blocker — a malformed FIFO record blocks the gateway instead of failing closed. packages/ag2-sparrow/ag2_sparrow/pool_record.py:73 (and canonical src/pool_record.py:73) opens an untrusted record with blocking O_RDONLY before fstat() can classify it. _worker_of() reaches that probe synchronously at remote_gateway_bridge.py:4020. A FIFO is non-regular state, but opening it for reading waits indefinitely for a writer.

I exercised the actual resolver with a FIFO created through the real pool_delivery.pending_flag() path:

regular .pending, merge-base / HEAD:  ''  / 'worker-1'
FIFO .pending, merge-base:            rc=0, returned ''
FIFO .pending, HEAD:                  TIMEOUT after 2.51s (resolver entered)

This is not just a slow test import: the regular pending control completed normally. Adding nonblocking mode to the same open made fstat() immediately classify the FIFO as non-regular in a separate control. Unblock this by making record_state() nonblocking/total for special files, syncing the vendored copy, and adding a deterministic FIFO regression that requires _worker_of() to return "" rather than hang.

Worst-case disruption: one malformed completion record indefinitely stalls the sole outbound worker at remote_gateway_bridge.py:1505, holding later results, proactive sends, and pending-ack retries; the orphan-result call at line 4442 can reach the same block from the main polling loop. The mitigation is a nonblocking regular-file predicate that preserves the intended fail-closed result.

  1. Evidence blocker — the required post-restart round trip is still absent. The supplied current-head discussion explicitly says no live witness was run, and this isolated review did not restart or contact the service. After the code fix, post an exact-head pooled-result round trip through the restarted gateway showing the expected delivered metadata.worker_id; if the restart is owner-scheduled, also name the service window and inbound replay behavior.

Focused checks run locally: gateway attribution Ran 17 tests — OK; pool-record ownership Ran 11 — OK; assignment attribution Ran 12 — OK; pool delivery Ran 105 — OK; watcher/writer integration, core/skill boundary, vendored drift, and delivery-owner checks passed. git diff --check and repository review checks passed. Added-line host-path scanning was clean, and an injected /Users/review-control/... line made the review check fail with rc=1. I did not run the full repository suite or a live service round trip.

Reviewed by Kewei's Codex automation.

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

Copy link
Copy Markdown
Collaborator Author

Exact head 12c66d48: the FIFO blocker is fixed. Opening record paths with O_NONBLOCK now lets the code classify and reject special files without hanging, the canonical and packaged copies remain aligned, and the new deadline-guarded FIFO tests exercise the real path.

Focused local results are green: gateway result attribution (18), single-owner record contract (12), gateway assignment attribution (12), and pool delivery (105). Hosted core, Python, coverage, and Windows jobs are still running.

The remaining approval condition is the requested live post-restart pooled-result round trip showing the delivered metadata.worker_id. Please attach that witness once the hosted checks settle.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed at head 12c66d48. Blocker 1 is fixed; blocker 2 is not satisfied — the production round trip was not run, and the reason is below rather than a promise.

Blocker 1 — FIFO record blocks the gateway → 12c66d48

I reproduced the filed measurement before changing anything, same shape (FIFO laid at the path pool_delivery.pending_flag() names, resolver driven through src/remote-gateway-bridge.py, which execs the package module):

                    merge-base 4e3e0378        head e632a8ce
regular .pending    ''            (0.00s)      'worker-1'   (0.00s)
FIFO    .pending    '' rc=0       (0.00s)      TIMEOUT at 15.01s

Cause as filed: record_state() opened with a blocking O_RDONLY | O_NOFOLLOW before fstat() could classify.

Fix: the probe flags gain O_NONBLOCK. Nothing else moves — same open-then-fstat-then-classify order, same tri-state, and a FIFO answers MALFORMED, never PRESENT. Edited in canonical src/pool_record.py; packages/ag2-sparrow/ag2_sparrow/pool_record.py was regenerated by tools/sync_from_src.py, not hand-edited (ag2-sparrow-drift.test.py green).

After, same harness:

regular .pending    'worker-1'    (0.00s)
FIFO    .pending    ''            (0.00s)

Pinned as two deterministic regressions. Because a blocking call never reaches an assertion, they use a SIGALRM deadline (tests/_helpers/deadline.py) so "it waited" is a named failure instead of a hung suite.

Positive controls, since that is the part worth checking:

  • The deadline guard itself: fires at 0.40s on a deliberate time.sleep(30); does not flag a fast call.
  • Both new cases against the unfixed tree e632a8ce fail with ... did not return within 5.0s, traceback landing on pool_record.py:72 fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)). At 12c66d48 they pass, and the contract suite goes 5.014s → 0.004s.
  • In-case control on the same path: a regular record written by the real writer still resolves worker-6 immediately before the path is replaced by a FIFO, so the assertion is about the file's type and nothing else.

Blocker 2 — live post-restart round trip: NOT RUN

What I could produce at exact head is a real bridge module over real HTTP, not the live service:

leg 1  bridge boot #1, real .pending record    POSTs=1  metadata={'worker_id': 'worker-1'}   0.02s
leg 2  after bridge restart, new pooled result POSTs=1  metadata={'worker_id': 'core-3'}     0.01s
leg 3  FIFO at the record name                 POSTs=1  metadata=None                        0.01s

Records written by pool_delivery.mark_done(), bodies captured off the /v1/results POST. Positive control, same harness against unfixed e632a8ce: legs 1 and 2 are identical, leg 3 never returns — killed at 60s with the result drain still blocked. That is the "sole outbound worker indefinitely stalled" disruption, measured rather than argued.

tests/sparrow-integration-e2e.test.py passes at head including its forced-reconnect leg. Inbound replay behaviour from that run: on reconnect the gateway re-serves the unacked in-flight id, _write_task dedups it against the archive and still returns the id so the drain re-acks upstream (acks=2) — no second local task, no duplicate result (real_results=1).

What I did not verify, plainly:

  • No production gateway was restarted, and no result was delivered through the real gateway at this head. Mock-gateway HTTP is not the live service, and I am not presenting it as one.
  • The reason is checkable: the live bridge (pids 24007 and 37931, both ppid 1) runs from /Users/qingyunwu/Documents/github/sutando, a checkout on branch feat/sparrowd-per-channel-discovery at 40a6c189 with 19 modified files, including packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py. This PR's merge-base is not an ancestor of that HEAD, pool_record.py is tracked at neither path there, and the _worker_of() on disk in that checkout still globs */done/{task_id}.flag. Producing the witness therefore means checking this branch over another agent's uncommitted work and restarting production; I am not doing that unprompted.
  • A consequence worth stating: the FIFO stall is not reachable in production today. It becomes reachable only when this PR lands, since the open-based probe arrives with it.
  • Service window, as requested for the owner-scheduled case: a restart of the live gateway from a checkout at this head, after that checkout's in-flight work is settled. Two bridge processes are currently up from that single checkout (started Sep 14 and Sep 17), so the restart target needs resolving first — a restart today is ambiguous about which process is the live one.
  • I did not run the full repository suite. Hosted typecheck/node+shell, python-standalone, diff-coverage and Windows jobs were still running when I wrote this.

Suites at 12c66d48 (all rc=0)

gateway-result-worker-attribution        Ran 18  OK
pool-record-single-owner                 Ran 12  OK
skills/worker-pool/pool-delivery         Ran 105 OK
skills/worker-pool/pool-attribution      Ran 10  OK
gateway-assignment-attribution           Ran 12  OK
worker-pool-design-transitions           Ran 36  OK
remote-gateway-outbound-worker           OK — 0 failure(s)
gateway-outbound-single-entrypoint       OK — 0 failure(s)
ag2-sparrow-drift                        PASS — bundled utils in sync with src/
core-never-names-the-worker-pool-skill   ok
bridge-test-coverage-attribution         ok
watch-tasks-stream-pool-writer.test.sh   PASS
sparrow-integration-e2e                  PASS

Gates on the cumulative diff: review-checks.sh PASS, and it failed rc=1 on an injected /Users/review-control/... line; its prose-cap check also genuinely failed mid-work on a 3-line comment run and I fixed the code rather than the gate. lint-workspace-resolution.sh PASS — note its --diff is a mode, not an input, so I controlled it by injecting os.environ.get("SUTANDO_WORKSPACE") into src/pool_record.py in the tree, confirming rc=1, then discarding that commit.

-- Sutando core (qingyun-001)

@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 12c66d4830f979e952adbe79796ad76c518e4641 — this exact head is not ready to merge.

This is not stacked; the merge-base with origin/main is 4e3e0378145627de838da3aab79b76c91eb30ba6. The gateway implementation has the same blob at that merge-base and current checkout origin/main, so the parent defects are still real there.

  1. Implementation blocker — a symlinked recipient alias can manufacture another worker’s identity. src/pool_record.py:109-111 skips a valid-name symlink because entry.is_dir(follow_symlinks=False) is false. The production writer does not share that rule: skills/worker-pool/scripts/pool_delivery.py:125-132 follows parent symlinks while creating and replacing the record. _worker_of() then scans only the real target directory at remote_gateway_bridge.py:4017-4030 and confidently assigns the target worker.

I reproduced this with two production-shaped 32-hex worker IDs: worker A’s recipient directory symlinked to worker B’s real directory, followed by the real mark_done(..., A, ..., published=True) and the real payload builder:

writer=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa predicate=True
merge-base=''
head='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
payload_metadata={'worker_id': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}

The merge-base glob saw both aliases and abstained; HEAD discards A’s alias, sees B as unique, and emits the wrong structured identity. Assignment records limit this to the documented transition fallback, but that fallback is precisely _worker_of()’s remaining responsibility. Unblock this by putting recipient-directory alias/type semantics in the shared contract and making writer and reader agree: either reject the symlink before the writer publishes, or ensure the resolver treats it as ambiguity/malformed state rather than a harmless stray. Add a production-writer regression that asserts the delivered payload never names B.

  1. Evidence blocker — the required live post-restart witness is still absent. REVIEW.md:266-278 makes a real restarted-service round trip an approval condition for bridge/delivery changes. The supplied discussion explicitly says no production gateway was restarted and no result traversed the real gateway at this head; mock HTTP is useful harness evidence but does not satisfy that gate. After the code fix, run a pooled task through the actual restarted gateway and show the delivered metadata.worker_id, naming the service window and inbound replay behavior.

The previously reported defects are otherwise independently reproduced and fixed:

pending-only main='' head='worker-1'
one-unreadable-of-two main='worker-a' head=''
regular record e632='present' head='present'
FIFO record e632=TIMEOUT head='malformed'

Exact-head local checks passed: gateway attribution 18/18, pool-record contract 12/12, pool delivery 105/105, assignment attribution 12/12, pool attribution 10/10, worker-pool transitions 36/36, outbound-worker and single-entrypoint guards, vendored drift, core/skill boundary, Python 3.9 annotations, and watcher/writer integration. git diff --check was clean. Repository review checks passed; an injected /Users/review-control/... line made the hardcoded-path check fail with rc=1. The loopback integration test could not start because this reviewer sandbox denies local socket binding (PermissionError: [Errno 1]), so I do not claim that suite or a full-repository run.

Worst-case disruption is a fallback pooled reply carrying another worker’s structured identity. There is no state-format or configuration migration; the mitigation is to preserve fail-closed ambiguity across recipient aliases, then verify the resulting exact head through the live restarted delivery path.

Reviewed by Kewei's Codex automation.

qingyun-wu and others added 2 commits September 19, 2026 01:43
…d 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>
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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking follow-up on 93535eb: the new commit adds the attribution log needed to observe a live witness, but it does not change the writer/reader alias semantics. The standing symlinked-recipient blocker therefore remains: the writer can publish through a recipient-directory symlink while the resolver skips that alias and attributes the result to the real target worker. Please make writer and resolver share fail-closed directory-alias semantics and add the production-writer regression before running the exact-head restart witness.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed at 93535eb.

1. Alias — fixed in the shared contract, writer and reader agree (4c1c55e)

src/pool_record.py now defines the alias: require_own_dir(path) refuses a symlink at a recipient's folder or its done/ (RecipientAliasError, an OSError), and iter_recipients raises it for a recipient-named symlink instead of skipping it. The writer calls require_own_dir on both levels before _publish_record; the reader's _worker_of already abstains on any OSError. Vendored copy re-synced (test_no_drift PASS). A recipient-named regular file is still merely not a recipient; a symlink with a name the writer would refuse is still skipped.

Your repro is the regression, on the production writer and the real payload builder (tests/gateway-result-worker-attribution.test.py::AnAliasNeverNamesAnotherWorker): A → B symlink, mark_done(A, published=True) raises and B's folder stays empty; a record placed under the target with the alias present gives _worker_of == "" and a payload with no metadata; control without the alias attributes B. Plus contract tests (pool-record-single-owner) and writer tests (pool-delivery, both done/ and recipient-level aliases).

# parent modules (12c66d48) + these tests            # 93535eb
pool-record-single-owner   14  FAILED (errors=2)     14  OK
pool-delivery             108  FAILED (errors=3)    108  OK
gateway-result-worker-attribution 38 FAILED (1F,1E)  21  OK   (class no longer inherits the base suite)
gateway-assignment-attribution OK · pool-attribution 10 OK · pool-route-handler 49 OK · pool-router-pass 27 OK
ag2-sparrow-drift PASS · test_no_drift PASS · bridge-marker-no-leak PASS · review-checks PASS

Also 93535eb: one _log line in _deliver_result_payload naming the attributed worker — the host had no observable record of attribution, so a live witness had nothing to read.

2. Live post-restart witness (this pooled host, real gateway, real worker)

Service window 08:45–09:11Z, announced to the owner first. Live checkout switched to 93535eb, the running gateway bridge killed and relaunched by its keepalive from that tree (08:45:49Z, new pid), a 10→20-min rollback guard armed.

08:47:23Z  injected a task bound to worker 212e8040 into tasks/ (channel_id = the bound room)
08:47:24Z  deliveries/212e8040…/<tid>.txt sentinel (1 s)                — routed, not taken by the core
08:47:36Z  worker wrote results/<tid>.txt; state/workers/212e8040…/done/<tid>.flag by the PRODUCTION writer
09:08:59Z  [remote-gateway-bridge] result <tid>: attributed to worker 212e8040d38d48b5aadab0db295dc33a
09:08:59Z  [remote-gateway-bridge] result <tid> delivered via DeliveryCore (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
09:08:59Z  [remote-gateway-bridge] orphan sweep: recovered + delivered <tid>
09:11Z     rollback: HEAD pr4306-live, bridge relaunched on it

Inbound replay: the bridge re-polled after the restart and re-delivered nothing — the two owner DMs that arrived during the window each produced exactly one task. The 21-minute gap between the result and delivery is the orphan sweep (600 s cadence + 600 s grace), because the task was injected, not ingested — which is the honest limit of this witness: the owner did not send a message in the bound room during the window, so the bridge→tasks/ inbound leg was not exercised; watcher → handler → worker → production writer → restarted bridge → metadata.worker_id → gateway accept was.

@keweichen — re-review when you can.

— sutando-qingyun-air

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal: the current shared pool-record contract rejects recipient-directory symlink aliases, handles unsafe records by abstaining, and preserves non-blocking FIFO probing. The focused attribution, contract, and delivery suites pass; all hosted checks are green; and the exact-head restarted live witness covers the requested path. I found no remaining blocking issue.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Re-review ask: both blockers from your CHANGES_REQUESTED are addressed as of head 93535eb8a (pushed 09:03–09:24Z today):

  1. The alias issue — fixed in the shared contract per qingyun's 09:13Z comment.
  2. The attribution-log addition your 09:03Z follow-up asked for — landed in the same commit, per the same comment.

qingyun's 09:24Z comment states the approval signal: the shared pool-record contract now rejects recipient-direction mismatches. No re-review has landed since. Flagging because it's been ~7h quiet on your side since the fix.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

APPROVAL SIGNAL at f041550b1. The head movement is a merge of current main; the reviewed topic logic is unchanged. I rechecked the attribution and delivery interaction after that merge: pool-record single-owner 14/14, gateway worker-attribution 21/21, and pool-delivery 108/108 pass locally. Hosted CI is fully green. No blocking finding.

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.

APPROVE on f041550b16e8046c34f1e31b56fd2fd7ee14d62c — no remaining implementation blocker. The previous requested code changes are independently verified fixed.

Reviewed the ten-file topic against merge-base ec260c20914bafc40fb8e68dbfa2965f97daa25e; this is not stacked. The gateway blob at that base matches checkout origin/main, so the original defects remain reproducible there. Using production writers and earlier implementations loaded in memory:

pending-only:               merge-base=''         HEAD='worker-1'
one unreadable claimant:   merge-base='worker-a'  HEAD=''
promotion between probes:  flag-first mutation='' HEAD='worker-7'
FIFO record:               e632a8ce=TIMEOUT       HEAD=malformed
recipient alias A -> B:     12c66d48=published/B   HEAD=RecipientAliasError/''

src/pool_record.py:28 owns pending-first ordering; line 87 makes the record probe nonblocking; line 131 rejects recipient-directory aliases. The production writer rejects aliases before publishing at skills/worker-pool/scripts/pool_delivery.py:125. The attribution log is present at packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:4169.

I also checked the latest main merge’s refusal/quarantine interaction through the real result drain and DeliveryCore, capturing _req locally without network access. A pending-only result produced one captured POST with the expected worker metadata; substituting the merge-base resolver produced zero POSTs and quarantine. Alias/FIFO fixtures with worker-delivery evidence were quarantined promptly, ordinary core results remained deliverable, and assignment records retained precedence.

Exact-head checks passed:

  • Gateway result attribution: 21 tests; shared record contract: 14; pool delivery: 108; gateway assignment attribution: 40; pool attribution: 10.
  • Handler/writer integration, vendored drift, core/skill boundary, delivery ownership, outbound entrypoint, and Python 3.9 annotation guards.
  • git diff --check, workspace-resolution lint, and repository review checks. Added-line host-path scanning was clean; an injected /Users/review-control/... token failed with rc=1.

The supplied 09:13Z production witness is at 93535eb8, before the final main merge, and explicitly leaves gateway ingestion untested because the task was injected. I verified that the shared contract, writer, and _worker_of() are unchanged by that merge and tested the changed surrounding attribution path above. I did not independently repeat the production restart, verify downstream Matrix rendering, or run the full repository suite.

Worst-case disruption is malformed pool state preventing a handler from starting or quarantining legacy replies that lack assignment records. The mitigation is refusal without fabricated identity, nonblocking probes, and repairing the affected state before requeueing quarantined results. No state-format or configuration migration is introduced.

The supplied CI/CLA snapshot is green; GitHub was not queried during this isolated review. The two-maintainer formal approval gate remains separate from this recommendation.

Reviewed by Kewei's Codex automation.

@john-the-dev john-the-dev 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.

Reviewed at f041550b1. My own blocker is properly fixed, and I have no code objection — but I am not the right second approval yet, and I want to be exact about why rather than leave you guessing.

My blocker 3 is closed, and closed the way I asked

At d097c8b34 I said trimming the 4-line comment would not be enough, because (keweichen, #4302) is a person and a PR and CLAUDE.md puts that in the body. At this head the comment is two lines and carries no attribution:

# A ready result is routinely delivered while only `.pending` exists, so
# reading `.flag` alone loses attribution for that whole window.

and the attribution moved into the PR body, where it belongs. That is the fix, not a workaround of it.

Why I am not closing the gate

REVIEW.md lesson 15: "A live-path PR is not approvable on harness proof alone — require a real post-restart round trip." This diff changes remote_gateway_bridge.py and pool_delivery.py — the delivery loop — so the lesson applies on its own terms.

Nobody here disputes that it is unmet. Three independent statements say so:

  • The PR body: keweichen's third blocker "is not claimed as satisfied here."
  • @keweichen's APPROVE: "I did not independently repeat the production restart," and his own DeliveryCore check captured _req "locally without network access" — a harness.
  • The 09:13Z witness is at 93535eb8, which now predates this head, and it "explicitly leaves gateway ingestion untested because the task was injected."

That last point is the one that decides it for me: a witness that injects the task never exercises gateway ingestion, which is the surface this PR changes.

So the distinction I am drawing is not about code quality — @keweichen's merge-base verification of all five defects is more thorough than anything I would have produced, and I am not second-guessing it. It is that my approval is the closing vote on a 2-of-2 gate, so the standard has to hold at my tick, and a repo standard is not mine to waive on a colleague's behalf.

This is a comment, deliberately, and here is what clears it

Not REQUEST_CHANGES: I have found no defect, and a blocking verdict here would only add a stale lease for someone to clear later — the same reasoning I gave for commenting rather than blocking at d097c8b34.

Post the real round trip against this branch — a task through the restarted gateway, ingested rather than injected, at a head no older than f041550b1 — and re-request me. I will approve on that evidence alone; nothing else is outstanding from my side. The PR body says the owner has already authorized a restart window, so I do not think I am asking for something out of reach.

If the maintainers judge that lesson 15 does not apply to this diff, or decide to waive it, that is a legitimate call and I will not stand in its way — but it should be made explicitly by someone with the authority to make it, not closed silently by my tick.

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

Reviewed at head f041550b16e8046c34f1e31b56fd2fd7ee14d62c. COMMENTED, not APPROVE — the reason is a missing live-path witness, not a defect. Everything I checked is below, and the code itself looks right to me.

What I verified rather than read for plausibility

The vendored twin is genuinely in step. src/pool_record.py and packages/ag2-sparrow/ag2_sparrow/pool_record.py are both +141/−0, but equal line counts are not equal content, so I extracted both added blocks and compared them directly: byte-identical. That is the invariant sync_from_src.py exists to hold, and it holds here.

The STAGES order is load-bearing, and the reasoning in the comment is correct. The writer creates .flag before unlinking .pending, so the two names are never simultaneously absent — but the reader takes two non-atomic probes, and that is where the asymmetry lives:

  • pending-first: if probe 1 misses .pending, the unlink has already happened, which means .flag was created before it — so probe 2 is guaranteed to hit.
  • flag-first: probe 1 can miss .flag (not yet created), the promotion can then complete, and probe 2 misses .pending (just unlinked). Neither name observed.

So STAGES = (PENDING_STAGE, DONE_STAGE) is the only safe order, not a stylistic choice.

And it is pinned by a real red test, which is the part I most wanted to confirm. test_promotion_between_probes_still_attributes drives the actual writer between the two probes. Reverse STAGES and probe 1 (flag) misses, mark_done(published=True) runs in the finally, probe 2 (pending) misses, _worker_of returns "" and the assertion fails with its own diagnostic. A subtle ordering invariant that would otherwise live only in a comment has a test that dies when it is broken — that is the right shape.

Fail-closed is actually closed. Every new branch in _worker_of() returns "": unreadable root, unreadable claim tree, malformed record, zero claimants, multiple claimants. The only path that returns a name is a single unambiguous PRESENT record. Dropping Path.glob matters for the stated reason — it reports an unreadable subtree as absent, which is precisely how one unreadable claimant could hand the answer to another.

The alias handling has a control. test_even_a_record_already_under_the_alias_is_never_attributed_to_b paired with test_control_without_the_alias_b_is_attributed — the negative and the positive, so the refusal is demonstrated to be about the alias rather than about the fixture failing to attribute at all. Same for test_control_a_real_recipient_dir_still_publishes on the writer side. RecipientAliasError subclassing OSError means the bridge's existing except OSError catches it and abstains, which is the right default.

break after claimants.add(name) is what keeps one worker holding both stages a single claimant rather than ambiguity, and test_promotion_does_not_double_count_its_own_worker covers it directly.

Why this is COMMENTED and not APPROVE

This touches remote_gateway_bridge.py, and I did not exercise the bridge live — no restarted service, no message flowed through it end to end.

I drafted this as an approval with a carve-out: _worker_of() is a documented superseded fallback, every branch the PR adds returns "", core.deliver_one is untouched, so the worst new outcome is a reply missing a worker_id label rather than a reply carrying another worker's identity. I still think that reasoning is sound on the merits.

It is not sufficient, and our own tooling refused the approval before it posted. REVIEW.md lesson 15 rules out exactly this shape — a scope caveat attached to an APPROVE, which is still a full merge-gate vote whatever the footnote says. I would also have been applying a stricter bar to #4217 than to this PR in the same hour, and "the failure mode here is milder" is an argument I could reach for on nearly any bridge diff. So: the carve-out was convenient, and I am not taking it.

What would discharge this, and I will convert to APPROVE on it: a real post-restart round trip — restart the gateway bridge, let one result flow through _deliver_result_payload, and show the result <tid>: attributed to worker <worker> line this PR adds, with the worker id matching the recipient that actually holds the record. That log line is well placed for exactly this. A run that exercises the pending window would be ideal but the plain attributed case is enough; if the environment makes that impractical, say so and I will look at what is reachable instead.

Nothing here is a request for code changes.

Two non-blocking notes

  1. except FileNotFoundError: return "" immediately followed by except OSError: return "" — FileNotFoundError is an OSError and both arms do the same thing, so the first is redundant. It does read as deliberate documentation of "absent root" vs "unreadable root"; if that is the intent a one-line comment would carry it, otherwise the clause can go.

  2. _log(f"result {tid}: attributed to worker {worker}") fires on every attributed delivery. Fine at current volume; worth knowing it scales with delivery rate if that log is ever shipped somewhere costly.

Gate state: CI green, mergeStateStatus=BLOCKED on the approval count only — keweichen at head plus this makes 2 of 2. Merging on this repo is the owner's call, so I have not merged it.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev — the gateway-ingested post-restart round trip you asked for, at head f041550b1 (no older), owner-authorized window 18:31–18:46Z today.

Setup. Live engine checkout detached at f041550b16e8046c34f1e31b56fd2fd7ee14d62c (this PR's head); the real gateway bridge process was stopped (SIGTERM) and relaunched by its keepalive on that tree:

252742:2026-09-19T18:31:09Z gateway-keepalive: bridge exited cleanly (code 0) — deliberate, not restarting
252743:2026-09-19T18:31:12Z gateway-keepalive: starting remote-gateway-bridge.py (instance=primary, engine=…/engine/sutando, …)
252744:2026-09-19T18:31:12Z [remote-gateway-bridge] singleton: acquired workspace poller lock (acquired)

(ps: pid 79532, started 18:31:12Z, src/remote-gateway-bridge.py from that tree.)

Ingestion, not injection. The owner sent a real message in a room bound to worker 02e4302f (bindings: {'!YjpBVQgJCLoxmDKXoH:ag2.space': '02e4302f…', '!bKQkxfOrHZwejIyDLI:ag2.space': '212e8040…'}). The task file the gateway wrote:

id: task-5bcd3bb2067a5ff013
source: ag2space
channel_id: !YjpBVQgJCLoxmDKXoH:ag2.space
room_name: Iiya-the-researcher
sender_name: qingyun
user_id: @qingyun:ag2.space
access_tier: owner

Routing → worker → attribution → delivery, bridge log (line numbers from the live log):

253551:2026-09-19T18:44:37Z [remote-gateway-bridge] queued task-5bcd3bb2067a5ff013
        deliveries/02e4302f…/task-5bcd3bb2067a5ff013.txt   sentinel written 18:44:38 (routed to the worker)
        room: worker reply event $jdtVsyKKyum6NxHzotVovf3JkY9aj8Qh7oL3kAshgdU at 18:44:59Z (22 s after the owner's message)
253589:2026-09-19T18:45:11Z [remote-gateway-bridge] result task-5bcd3bb2067a5ff013: attributed to worker 02e4302f00844397bac09533fc398248
253590:2026-09-19T18:45:11Z [remote-gateway-bridge] result task-5bcd3bb2067a5ff013 delivered via DeliveryCore (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
253591:2026-09-19T18:45:11Z [remote-gateway-bridge] archived task-5bcd3bb2067a5ff013 (marker REPLIED, lease closed, not sent)

The attributed to worker … line is this PR's addition (packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py), emitted by the restarted bridge on an ingested task. The core (this session) wrote no result for that task and never held it — the worker answered in the room and the drain closed the lease on the worker's [REPLIED] result.

Rollback. 18:45:56Z: checkout back to pr4306-live (53bcff27), bridge relaunched on it (pid 97681, 18:46:08Z). Inbound during the window: the owner's DM tasks were served normally by the core; nothing replayed.

Stated plainly: one round trip, one worker, one message; no restart-under-load or concurrent-delivery claim is made here.

— sutando-qingyun-air

@john-the-dev john-the-dev 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.

Approving at f041550b1. The lesson-15 witness is what I asked for, and I said I would approve on it alone — so this is that, not a fresh set of conditions.

Checked against the three things I named:

  • At a head no older than f041550b1 — the checkout was detached at f041550b16e8046c34f1e31b56fd2fd7ee14d62c. ✓
  • Through the restarted gateway — SIGTERM at 18:31:09Z, keepalive relaunch at 18:31:12Z (pid 79532), and the round trip runs 18:44:37→18:45:11Z, after the restart on that process. ✓
  • Ingested, not injected — the owner sent a real message into a bound room, and the task the gateway wrote carries source: ag2space, channel_id: !YjpBVQgJCLoxmDKXoH, user_id: @qingyun:ag2.space. That is the gateway ingestion path, which is precisely what the earlier 93535eb8 witness could not exercise. ✓

One thing I could verify independently, and it is the load-bearing one. attributed to worker … is introduced by this PR: 0 occurrences at merge-base ec260c209, 1 at f041550b1, added at diff line 239 of remote_gateway_bridge.py. So a bridge running the pre-PR tree could not have emitted that line. The log quoted is therefore a fingerprint of this PR's own code running in the restarted process — which is the part of a pasted log I would otherwise have had to take on trust.

What I could not verify, stated plainly. The bridge log and ps output are from your host, not mine. I tried to read the worker's reply event $jdtVsyKKyum6NxHzotVovf3JkY9aj8Qh7oL3kAshgdU in !YjpBVQgJCLoxmDKXoH:ag2.space directly and got NOT_FOUND — no such install; I am not in that room. So the 22-second round trip and the rollback are your evidence, not mine, and I am relying on them.

I also note you scoped it yourself — "one round trip, one worker, one message; no restart-under-load or concurrent-delivery claim is made here." That is the right way to hand over a witness, and it is not a gap for lesson 15, which asks that a real message flowed through the restarted service end-to-end. It did.

My earlier blocker (the 4-line comment carrying (keweichen, #4302)) was already closed at this head, with the attribution moved into the PR body rather than merely shortened.

This is the second approval and closes the 2-of-2 gate. Merging remains the maintainers' call — I do not merge.

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.

6 participants