Skip to content

feat(gateway): a bare [thread] asks the broker to thread a task answer on its ask - #5326

Merged
sonichi merged 8 commits into
mainfrom
feat/result-thread-ask
Oct 11, 2026
Merged

sonichi merged 8 commits into
mainfrom
feat/result-thread-ask

Conversation

@sonichi

@sonichi sonichi commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #5305 (merged) and #5323 (closed). Depends on ag2-space/ag2space-backend#2138 for effect; safe to merge first: a broker without that change ignores the new field, so until it deploys a [thread] answer is posted exactly as today (marker stripped, plain reply).

What changed, and why?

#5323 tried to thread a task answer from the gateway (hand the answer to the proactive leg, close the lease no_send). Its reviews showed why that cannot be done on the client: the answer left the outbox, a refused thread post lost it, an unknown outcome duplicated it, the sentinel was not generation-bound, unreadable provenance was guessed, and the root was not proven to belong to the task. backend#2138 moves placement to the server: POST /v1/results {"id", "body", "thread": "ask"} roots a thread on the task's own asking message, keeps an in-thread ask in its thread, falls back to a plain reply where threading is impossible, and answers 400 (nothing recorded, lease kept) for any other value.

This PR is the gateway side, and only that:

  1. Marker (src/result_markers.py, vendored copy regenerated by sync_from_src.py). A leading line that is exactly [thread] (trailing blanks allowed; [thread]ing … or [thread] text is prose and untouched), in any order with [channel:]/[thread: $root]/[reply:], emits a thread-ask action and is stripped. It is distinct from feat(markers): [thread: $root] posts a proactive result inside a thread #5305's [thread: $root] (different regex, different action), carries no id, and is repeated-safe. An inline [thread] in prose is left alone; skip markers stay terminal; neutralize_markers knows the new word.
  2. Wire field (remote_gateway_bridge.py; name and value are RESULT_THREAD_FIELD/RESULT_THREAD_ASK in provider_ag2space.py, the one client owner, and docs/remote-gateway-protocol.md POST /v1/results is the canonical contract, citing backend#2138). The ordinary, non-redirected task-result branch of _post_ready_results passes thread_ask to _deliver_result_payload, which adds "thread": "ask" to the same doc. Nothing else moves: the marker is read from the guarded body (the same parsed the skip/redirect/attach decisions use), attribution runs before the publish, and the outbox/lease/idempotency path is unchanged. No proactive hand-off, no lease close before delivery. Skip, dedup, owner-mention and orphan-recovery paths never send the field (orphan recovery strips the marker and posts its labelled body top level, as today).
  3. A 400 parks (delivery_core/provider_ag2space.py). Only "ask" is sent and backend#2138 defines it as valid, so a 400 for the field is a bug to investigate: the provider makes its single attempt (sparrow-v1 row 5) and a 400 parks like any other 4xx, as on main. (An earlier head re-posted without the field; removed in round 3, see below.)
  4. Proactive files: a bare [thread] there is stripped, logged and posted top level; a rooted [thread: $root] on the same file wins in either order. Valid only on a task result.
  5. Voice task bridge (src/skip_marker_ownership.ts stripVoiceControlLines, used by both speak paths in task-bridge.ts): a leading standalone [thread] is removed before any voice/log callback, next to the existing standalone [dm-only] strip (same expression, moved into the helper).
  6. Docs. CLAUDE.md gets one marker bullet that also carries the rule: threading is the agent's call, a side discussion that would crowd the timeline is an example, and when unsure reply in the timeline. To stay under the 40960-byte cap, one sentence of the per-channel pull-namespace paragraph moves verbatim to docs/claude-md-moved-detail.md (40957 bytes; AGENTS.md regenerated). The detail doc has the full [thread] semantics. tests/claude-md-conduct-rules.test.py pins the new bullet.

[channel:] and attachments keep working exactly as today: the redirect is re-stitched as before and a redirected answer does not send the field; uploads run before the POST as before.

Review first

  1. remote_gateway_bridge.py: thread_ask in _post_ready_results and the doc["thread"] line in _deliver_result_payload.
  2. provider_ag2space.py: the RESULT_THREAD_FIELD/RESULT_THREAD_ASK constants (the one client owner of the field).
  3. src/result_markers.py: _THREAD_ASK_RE and the leading-marker loop.

What behavior does this prove?

A task result [thread]\nall green is posted as {"id": …, "body": "all green", "thread": "ask"} through the ordinary outbox path; without the marker the payload is byte-identical to today; a guarded result that is withheld never sends the field; a 400 for the field parks like any other 4xx.

Feature/documentation consistency

Updated: CLAUDE.md marker list (+ AGENTS.md), docs/claude-md-moved-detail.md "Result-marker semantics" (canonical). docs/catalog.json last_verified for that doc is already 2026-10-10. docs/README.md unchanged: navigation did not change.

Before / after

All runs under env -i HOME=$HOME PATH=/usr/bin:/bin:/opt/homebrew/bin. Tests are production-wrapper level: tests/gateway-task-result-thread-ask.test.py loads src/remote-gateway-bridge.py in-process (its real PROACTIVE_CLAIM_GATE is installed and asserted), with an isolated workspace and a fake _req, and drives the real _post_ready_results → delivery core → AG2SpaceResultProvider.

python3 tests/gateway-task-result-thread-ask.test.py at the parent d6f7abbd4 (test copied into a detached worktree), rc=1:

  ok   the loader's production claim gate is installed
  ok   a) one POST, got 1
  FAIL a) thread 'ask' sent, got None
  FAIL a) body clean, got '[thread]\nall green'
  ok   a) no event id is ever named, got keys ['body', 'id']
  ok   a) archived, out of flight
  ok   b) no thread key, got {'id': 'tt-b', 'body': 'all green'}
  ok   b) payload byte-identical to today, got {"id": "tt-b", "body": "all green"}
  ok   c) exactly the lease-close POST, got 1
  ok   c) no thread post, got [{'id': 'tt-c', 'body': '[no-send]', 'no_send': True}]
  ok   c) lease closed no_send, got [{'id': 'tt-c', 'body': '[no-send]', 'no_send': True}]
  ok   c) the withheld text never reaches the wire
  FAIL d) worker attribution kept with thread 'ask', got {'id': 'tt-d', 'body': '[thread]\nworker answer', 'metadata': {'worker_id': '0123456789abcdef0123456789abcdef'}}
  ok   d) refused attribution: no POST at all, got []
  ok   d) refused attribution: quarantined, not published
  ok   e) one upload to the task room, got ['/v1/rooms/%21taskroom%3Aexample.org/media']
  FAIL e) thread 'ask', file marker stripped, got {'id': 'tt-e', 'body': '[thread]\nhere you go'}
  FAIL f) redirect re-stitched, body clean, got '[channel: !other:example.org]\n[thread]\nmoved'
  FAIL g) two attempts, got 1
  FAIL g) first attempt asked for the thread
  FAIL g) re-posted without the field, got []
  ok   g) delivered and archived, not parked
  ok   g) nothing quarantined
  FAIL h) proactive: top level, body clean, got [{'op': 'message', 'room_id': '!taskroom:example.org', 'body': '[thread]\nnudge'}]

FAIL: 9 failure(s)

At head a24fe8f5c, rc=0:

  ok   the loader's production claim gate is installed
  ok   a) one POST, got 1
  ok   a) thread 'ask' sent, got 'ask'
  ok   a) body clean, got 'all green'
  ok   a) no event id is ever named, got keys ['body', 'id', 'thread']
  ok   a) archived, out of flight
  ok   b) no thread key, got {'id': 'tt-b', 'body': 'all green'}
  ok   b) payload byte-identical to today, got {"id": "tt-b", "body": "all green"}
  ok   c) exactly the lease-close POST, got 1
  ok   c) no thread post, got [{'id': 'tt-c', 'body': '[no-send]', 'no_send': True}]
  ok   c) lease closed no_send, got [{'id': 'tt-c', 'body': '[no-send]', 'no_send': True}]
  ok   c) the withheld text never reaches the wire
  ok   d) worker attribution kept with thread 'ask', got {'id': 'tt-d', 'body': 'worker answer', 'thread': 'ask', 'metadata': {'worker_id': '0123456789abcdef0123456789abcdef'}}
  ok   d) refused attribution: no POST at all, got []
  ok   d) refused attribution: quarantined, not published
  ok   e) one upload to the task room, got ['/v1/rooms/%21taskroom%3Aexample.org/media']
  ok   e) thread 'ask', file marker stripped, got {'id': 'tt-e', 'body': 'here you go', 'thread': 'ask'}
  ok   f) redirect re-stitched, body clean, got '[channel: !other:example.org]\nmoved'
  ok   g) two attempts, got 2
  ok   g) first attempt asked for the thread
  ok   g) re-posted without the field, got [{'id': 'tt-g', 'body': 'all green'}]
  ok   g) delivered and archived, not parked
  ok   g) nothing quarantined
  ok   h) proactive: top level, body clean, got [{'op': 'message', 'room_id': '!taskroom:example.org', 'body': 'nudge'}]

PASS: 0 failure(s)

python3 tests/result-markers-thread.test.py at d6f7abbd4, rc=1:

FAIL: bare [thread] emits thread-ask — [] '[thread]\nall green'
FAIL: bare [thread] after [channel:], attach kept — [('redirect', '!RoomIdAbCdEf:ag2.space'), ('attach', '/tmp/x.txt')] '[thread]\n\nmoved'
FAIL: bare and rooted forms are distinct actions — [] '[thread]\n[thread: $AbCdEf123_thread-root]\nboth'
FAIL: repeated bare [thread] is one action — [] '[Thread]\n[thread]\nonce'

At a24fe8f5c, rc=0:

ok: vendored copy parses identically
ok: vendored copy parses bare [thread] identically
ok: bare [thread] emits thread-ask
ok: bare [thread] after [channel:], attach kept
ok: bare and rooted forms are distinct actions
ok: repeated bare [thread] is one action
ok: inline bare [thread] is prose
ok: skip stays terminal over bare [thread]
ok: neutralized bare [thread] emits no action

All [thread:] marker invariants hold.

python3 tests/claude-md-conduct-rules.test.py (head test, main's CLAUDE.md): AssertionError: '- [thread] — task-result lead line: …' not found ×2, Ran 5 tests … FAILED (failures=2). At head: Ran 5 tests … OK.

Mutation checks (each restored afterwards)

  • Guard: parsed = parse_markers(body) → parse_markers(raw) in _post_ready_results:
    FAIL c) no thread post, got [{'id': 'tt-c', 'body': 'here is the key ghp_…', 'thread': 'ask'}]
    FAIL c) lease closed no_send, got [...]
    FAIL c) the withheld text never reaches the wire
    FAIL: 3 failure(s)
    
  • (Round 1 only: the 400 fallback this mutated was removed in round 3.)
  • doc["thread"] = "ask" removed: a, d, e, g fail (6 failures).

Tests run at the first head (a24fe8f5c, same env -i)

Gate files run explicitly: delivery-primitives-single-owner (OK — 0 failure(s)), events-plane-boundary (14/14 passed), agents-md-sync (5 passed; agents-md-sync.sh --check up to date), claude-md-conduct-rules (OK), pending-questions-review-r35 (size cap, OK; CLAUDE.md 40957 bytes).

80 files in total, 79 rc=0: every tests/gateway-*, tests/remote-gateway-*, every result-marker suite (result-markers, -golden, -reply, -thread, bridge-marker-no-leak, result-file-marker-guard, pathless-file-marker-controls, discord-bridge-prose-marker, dm-result-skip-markers, telegram-bridge-send-reply-markers, proactive-suppression-marker-*, health-check-*-skip-markers, startup-media-marker), ag2space-provider, outbox-receipt-destination, sparrow-result-guard, sparrow-integration-e2e, ag2-sparrow-drift, packages/ag2-sparrow/tools/test_no_drift.py, release-docs-audit. The one non-zero is tests/remote-gateway-bridge.test.py: FAIL env-fallback: no env token and no file yields empty token (no crash), which fails identically at d6f7abbd4 on this host (a host token is visible to the test), so it is not from this PR.

bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD): PASS (hardcoded-paths + root-artifacts + prose-cap clean). git diff --check origin/main...HEAD: clean.

CI at head a24fe8f5c: 34 checks pass, 2 skipped, including diff coverage >= 95% (python), refuse AGENTS.md stale vs CLAUDE.md and license/cla.

Round 2 (head ae7f6333c): Qingyun's nit and Kewei's review

Tests written first; each failed at 1e013d8d0 (env -i):

result-markers-thread: FAIL src/vendored: '[thread]ing is a library primitive' is prose, not a marker — [('thread-ask', '')] 'ing is a library primitive'   (8 failures: 4 shapes x src+vendored)
gateway-task-result-thread-ask: FAIL f) a redirected answer never asks for a thread, got {..., 'thread': 'ask'}
                                FAIL i) '[thread]ing is a library primitive' delivered verbatim, got {'body': 'ing is a library primitive', 'thread': 'ask'}
                                FAIL i) '[thread] prose on one line' delivered verbatim ...
ag2space-provider: FAIL one owner for the thread field: RESULT_THREAD_FIELD/ASK constants
task-bridge-thread-ask-strip.test.ts: 5 fail, incl. "voice spoke the marker: ["[thread]\nnudge body","[thread]\nanswer body"]"
gateway-proactive-thread (at a24fe8f5c): FAIL g) ask-first: the rooted form wins, got None

At ae7f6333c all pass. New provider cases pin the contract: thread+400 re-posts once without the field; thread+503 stays indeterminate with the field kept (one call); thread+422 is refused after one call; 400 twice is refused after two calls; 400 without the field is one call.

Full run at ae7f6333c under env -i: 90 Python files, 89 rc=0 (the same host-only remote-gateway-bridge env-fallback case that fails on main); delivery-primitives-single-owner OK — 0 failure(s), events-plane-boundary 14/14 passed; 27 TS files that import task-bridge or the marker module: 333/333 pass. review-checks.sh --diff PASS, git diff --check clean, sync_from_src.py --check in sync, AGENTS.md up to date, CLAUDE.md 40957 bytes. CI at ae7f6333c: 35 pass, 2 skipped, including diff coverage and license/cla.

Round 3 (head d8de155c7, rebased onto main b48528280): Kewei's review at ae7f6333c

  • Relay mode strips [thread] through stripVoiceControlLines before onResult/status; new real-relay-watcher test tests/task-bridge-relay-thread-ask.test.ts.
  • Skip ordering: skip/ownership/dedup decisions read the raw body in both watchers; [thread]\n<skip>\nvisible is an answer for all three skip forms (real-watcher test), [no-send]\n[thread] stays a skip.
  • Grammar: a bare [thread] counts only at a real line start (not after another marker on its line, not where a [dm-only] strip glued it); neutralize_markers quotes only the standalone form. 14 src+vendored regressions.
  • 400 fallback removed (single attempt, frozen contract kept); a 400 parks. The pre-existing malformed-2xx first-POST stranding is filed as gateway result outbox: a malformed 2xx on the first result POST escapes the provider and leaves the claim HELD #5329.

Before/after outputs: the round-3 PR comment. At d8de155c7 under env -i: 96 Python files, 95 rc=0 (same host-only env-fallback case as main); both gates green; 29 TS files 355/355.

Round 4 (head 69b5d9e4d): Qingyun's skip-parity finding and Kewei's review at d8de155c7

  • Skip right after the leading markers is a skip, in parse_markers (one owner), voice (bodyIsSkipMarked mirrors it) and the broker alike: [thread]/[thread: $root]/[reply:]/standalone [dm-only] followed by [no-send]/[REPLIED]/[deduped:]. The gateway archives it through its skip path (lease closed no_send, logged (marker …, lease closed, not sent); [deduped:] goes through the dedup plan). Not after [channel:]: there it stays text so the team guard still withholds redirect-plus-skip for owner review (team-guard-suppress-journaled-silent-close e).
  • Indentation: a bare [thread] indented from its physical line start counts again (byte zero and after every leading form); same-line and glued forms stay prose.
  • One shared table, tests/fixtures/thread-ask-cases.json, run against parse_markers, the vendored copy, neutralize_markers (Python) and stripVoiceControlLines + bodyIsSkipMarked (TS).

Edge cases checked

Withheld team result (no field, no text on the wire); refused attribution (no POST at all, quarantined); attribution plus the field together; [file:] upload kept; [channel:] re-stitch unchanged; 400 parks; proactive bare [thread] top level; inline [thread] prose untouched; skip before [thread] stays terminal.

Risks / rollback

No migration or config. Before the backend deploys, the only visible change is that a bare [thread] line is stripped instead of printed. Rollback is a revert.

Known gaps / follow-up

  • Live post-restart witness is owed after backend#2138 deploys (task result with [thread] → gateway restart from this head → answer appears in a thread on the ask, lease closed). This PR changes the result delivery loop, so that round trip is required before merge-ready; it cannot be meaningful until the broker honours the field.
  • The voice task bridge (local, reconcile and relay paths) strips a bare [thread] before speech/logs and mirrors parse_markers' skip verdict (src/skip_marker_ownership.ts); a rooted [thread: $root] is kept, as forwarded voice results need it.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • packages/ag2-sparrow/ag2_sparrow/delivery_core/provider_ag2space.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (100%)
  • src/result_markers.py (100%)

Summary

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

@sonichi
sonichi marked this pull request as ready for review October 10, 2026 21:53
@sonichi
sonichi requested a review from keweichen October 10, 2026 21:53

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

Head moved to 1e013d8 while I was reviewing. I read the delta from a24fe8f: one commit, so that on a proactive file a rooted [thread: $root] wins over a bare [thread] in either order (remote_gateway_bridge.py _proactive_thread_root), plus test case g in gateway-proactive-thread. The fix is correct, proactive-only, and does not touch the task-result path reviewed below. At the time of writing, CI at 1e013d8 is still running (14 pass, 2 skipped, 18 pending) and license/cla shows no status yet (absent, not green).

Reviewed at a24fe8f. I found no blocker. This is not "Ready to approve" yet: nobody has approved this head, and I could not read the backend half.

What I checked

  • The new tests at head: gateway-task-result-thread-ask passes, and result-markers-thread and claude-md-conduct-rules both pass. With the parent's (d6f7abb) code and docs put back under the same tests: 9 and 4 failures, the same counts the PR body gives.
  • Revert control: I removed the 400 fallback in provider_ag2space.py:47. Only case g fails (4 checks).
  • Gates at head, all green: delivery-primitives-single-owner, events-plane-boundary, ag2-sparrow-drift, packages/ag2-sparrow/tools/test_no_drift.py, ag2space-provider, result-markers, result-markers-golden, bridge-marker-no-leak, sparrow-result-guard, agents-md-sync. scripts/review-checks.sh passes. CLAUDE.md is 40957 bytes. agents-md-sync.sh --check says AGENTS.md is up to date.
  • CI: 34 pass, 2 skipped. license/cla: success (1 context). No review threads.

The questions I was asked to check

  1. Can the 400 fallback post twice? The plain re-post runs in the same deliver() call, with the same outbox item and the same result id (provider_ag2space.py:41-52). The server dedupes on that id, so even a 400 that had already recorded the message could not produce a second post. If the re-post fails, the existing handlers take over unchanged. A 400 on it is a permanent refusal, the same as a 400 on main. A 5xx is indeterminate and gets retried. A timeout or URLError is indeterminate too. A retry sends the stored payload again, with the field, so it falls back again the same way.
  2. A 400 for some other reason, such as the body: the client sends one extra POST, gets a second 400, and refuses permanently, the same as main. Nothing is hidden. A 400 on an envelope without thread is still one call (with the condition loosened, the existing ag2space-provider test fails with IndexError: pop from empty list).
  3. Merging before the backend deploys: /v1/results ignored the unknown metadata key when #3611 shipped ahead of backend#882, so the server dropping unknown keys has precedent. But I could not check today's production behaviour, or #2138's claims ("400 records nothing", "ignored on a redirect", 400 and not 422). ag2-space/ag2space-backend returns 404 to this account while orgs/ag2-space returns 200, so the account lacks a grant to that repo. The name is not wrong. Note: if a strict server ever answered 422, the fallback would not fire and the answer would be refused for good (send_failure_policy.py:12; 422 is not transient).
  4. Marker grammar: the marker goes through src/result_markers.py (_THREAD_ASK_RE at :204, the loop at :313), and the vendored copy is byte-identical. [thread] and [thread: $id] give different actions. A skip marker still ends the parse, and the marker is stripped from the body. Orphan recovery posts parsed.body without the field (remote_gateway_bridge.py:5199).
  5. Other placements: [dm-only] already removes the redirect, so the answer goes to the asking room, as on main. A proactive file (which is how a voice result goes out) is stripped and posted top level (case h).

Nits (not blocking)

  • The client still sends "thread": "ask" alongside a re-stitched [channel:] (remote_gateway_bridge.py:4962). Only the backend ignoring it keeps that answer out of a thread, and that is the part I could not check. Dropping thread_ask when redirect is set would cost one line. Case f doesn't assert either way, so please pin it.
  • Test gap: if the fallback fires on any HTTP status instead of only 400, every test still passes (I tried it). Please add a case where the server answers 503 with the field: it should stay indeterminate and keep the field for the retry.

Still owed (named, not blocking): the live post-restart round trip once backend#2138 deploys, which the PR already lists.

What would make this "Ready to approve": one maintainer approval at this head, ideally from someone who can read backend#2138 and confirm the 400 and redirect behaviour.

— Sutando (susans-MacBook-Pro)

@sonichi sonichi closed this Oct 10, 2026
@sonichi sonichi reopened this Oct 10, 2026

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

Requesting changes at 1e013d8d0292c18cf51aebedcd45ca5ff0f29e23.

The happy path is well covered: I reproduced the task-result, parser, provider, proactive, docs-sync, diff, and review checks; the guard/attribution/attachment/outbox path stays intact, and all reported CI checks are green (two expected skips, CLA green). I also reviewed the one-commit delta from the requested a24fe8f5; it correctly fixes the proactive bare/root ordering case in both permutations.

There are still three code/contract blockers plus the repository's live-path gate:

  1. Blocking — the bare-marker regex silently truncates ordinary leading prose. _THREAD_ASK_RE is ^\s*\[thread\]\s*\n?, so it does not require [thread] to stand alone on a line. On this head:

    '[thread]ing is a library primitive' -> actions=[('thread-ask', '')], body='ing is a library primitive'
    '[thread] prose on one line'          -> actions=[('thread-ask', '')], body='prose on one line'
    

    That both edits the answer and opts it into the new wire behavior, contrary to the documented “leading line” rule. The current inline-prose test only covers text before the token (test lines 127-128). Please require horizontal whitespace followed by newline/EOF after ], and pin byte-zero adjacency in the source and vendored-parser checks.

  2. Blocking — the voice task bridge speaks/logs the control marker. It strips only [dm-only] when reading a result (task-bridge.ts lines 1636-1642), then deliberately narrates foreign-origin results through onResult (lines 1782-1812). An isolated current-head watcher probe produced:

    Result task-foreign-thread-probe.txt: [thread]\nanswer body
    {"spoken":"[thread]\nanswer body"}
    

    This contradicts the new “stripped everywhere” contract (marker docs). Please strip the same standalone marker before any voice/log callback and add a behavioral foreign-result regression test.

  3. Blocking — the canonical wire contract still does not define this field or its fallback guarantee. docs/catalog.json identifies docs/remote-gateway-protocol.md as canonical for the wire protocol, but POST /v1/results still permits only {id, body}, while its retry section says one POST per drain and other 4xx park (lines 281-296). The client only falls back on 400 (provider lines 43-57); a strict current-contract relay returning 422 for the unknown field yields one call and ProviderPermanentRefused, parking an answer that the plain payload would accept. The load-bearing “400 records nothing and keeps the lease” rule currently exists only as a client comment, and I could not independently inspect backend PR #2138 because that repository returns 404 to this account. Please update the canonical protocol with the optional field and exact no-side-effect/status contract, then make and test the compatibility path against that contract. Do not broaden the retry to another status unless the same no-side-effect guarantee holds.

  4. Blocking merge/approval gate — the real post-restart witness is still absent. The PR body already calls this out. REVIEW.md explicitly says a bridge/network/delivery-loop PR is not approvable on harness proof alone. Once backend #2138 is deployed, please run the exact-head gateway after restart and show one real [thread] task answer rooted on its ask, the lease closed, the outbox/result retired, and no duplicate or held claim.

What changes this verdict: fix and test items 1-3, provide the required exact-head live witness, and re-run current CI/CLA. I did not find a problem with the guarded happy path itself.

@sonichi

sonichi commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

New head ae7f6333c. Item by item, tests written first and failing at the previous head (outputs in the PR body, "Round 2"):

Qingyun's nit (proactive bare/rooted order) — fixed at 1e013d8d0: _proactive_thread_root decides on the rooted form first; case g in gateway-proactive-thread covers both orders (failed got None at a24fe8f5c).

@keweichen 1 — regex truncated leading prose. _THREAD_ASK_RE is now ^\s*\[thread\][ \t]*(?:\r?\n|\Z): the marker must be the whole line. [thread]ing is a library primitive, [thread] prose on one line, [thread]\t- not alone and [thread]x\nnext are prose in both the src and vendored parsers (result-markers-thread), and at the wire level are posted verbatim with no thread field (gateway-task-result-thread-ask case i).

@keweichen 2 — voice spoke/logged the marker. stripVoiceControlLines in src/skip_marker_ownership.ts (the existing TS marker module) removes a leading standalone [thread] (D7 header peeled, in any order with [channel:]/[thread: $root]/[reply:]) and keeps the standalone-[dm-only] strip unchanged; task-bridge.ts uses it on both speak paths, so no second inline parser. New tests/task-bridge-thread-ask-strip.test.ts drives the real watcher with a foreign-origin task result and a proactive file: before, voice spoke the marker: ["[thread]\nnudge body","[thread]\nanswer body"]; now both are spoken without it. Prose and [thread: $root] are kept. dm-only-strip-preserves-prose now pins the expression in the helper and that task-bridge hand-rolls none.

@keweichen 3 — no canonical wire contract. docs/remote-gateway-protocol.md POST /v1/results now defines the optional thread field with a link to ag2space-backend#2138: only "ask" is sent; the broker's placement rules; a broker that predates the field ignores it; any other value is 400, nothing recorded, lease kept. The retry section states the single exception: a 400 to a POST carrying thread is re-posted once without it, same attempt and result id; 422 and every other status keep today's handling. The client owner is one pair of constants, RESULT_THREAD_FIELD/RESULT_THREAD_ASK in provider_ag2space.py, imported by the bridge. ag2space-provider pins 400 (two calls, second plain), 503 (indeterminate, one call, field kept), 422 (refused, one call), 400 twice (refused, two calls), and 400 without the field (one call).

@keweichen 4 — live witness. Still owed, not run: after backend#2138 deploys, an exact-head gateway restart and one real [thread] task answer rooted on its ask, with the lease closed, the outbox/result retired and no duplicate or held claim.

@liususan091219 nits — a [channel:]-redirected answer no longer sends the field (case f pins it); the 503 case is in ag2space-provider.

Under env -i: 90 Python files, 89 rc=0 (the one failure is the host-only remote-gateway-bridge env-fallback case, which also fails on main); both gates green; 27 TS files touching task-bridge or the marker module, 333/333. review-checks.sh --diff PASS, git diff --check clean. CI is green at ae7f6333c.

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

Re-reviewed at ae7f633. I read the delta from 1e013d8 (my last review) and checked each of Kewei's items at this head. I found no blocker. This is not "Ready to approve" yet, because nobody has approved this head.

Kewei's blockers, checked at ae7f633

  1. Whole-line [thread]: fixed. _THREAD_ASK_RE is now ^\s*\[thread\][ \t]*(?:\r?\n|\Z) in src/result_markers.py:205, and the vendored copy is the same (sync_from_src.py --check: in sync). Revert control: I put the old \s*\n? back in both copies. result-markers-thread then fails 8 checks (4 shapes, src and vendored), and gateway-task-result-thread-ask case i fails twice ('[thread]ing is a library primitive' posts as 'ing is a library primitive' with thread: ask). With the fix restored, both pass.
  2. Voice spoke the marker: fixed. stripVoiceControlLines is in the existing TS marker module (src/skip_marker_ownership.ts:18), and task-bridge.ts uses it on both speak paths (:618, :1640). There is no second inline parser: the only other dm-only regex in src/*.ts is the detector DM_ONLY_RE (task-bridge.ts:190), and [thread appears nowhere else. Revert control: I disabled the [thread] line skip in the helper. task-bridge-thread-ask-strip.test.ts then fails 2 of 5 (voice spoke the marker: ["[thread]\nnudge body","[thread]\nanswer body"]). With it restored, 5/5 pass. That test drives the real result watcher, not the source text.
  3. Wire contract: fixed. docs/remote-gateway-protocol.md POST /v1/results now defines the optional field, the 400 no-side-effect rule, and the one retry exception (422 and every other status park as before). The field name and value are defined once, RESULT_THREAD_FIELD, RESULT_THREAD_ASK (provider_ag2space.py:20), and the bridge imports them (remote_gateway_bridge.py:316, used at :4650). There is no "thread" literal left on the wire path. These are Python-only constants. The TS side never sends the field, so there is no TS constant to keep in step. The provider test pins the constants and the status cases (400, then 503 indeterminate with the field kept, 422 refused after one call, 400 twice, 400 without the field).
  4. Live post-restart witness: still owed. The PR body names it. Under Susan's review policy it is named, not blocking.

My earlier nits

  • "thread" sent with a re-stitched [channel:]: fixed (remote_gateway_bridge.py:4964, redirect is None and …). Case f now asserts it. Revert control: with the guard removed, f) a redirected answer never asks for a thread fails.
  • No 503 test: added (above).
  • A strict 422 bypasses the fallback: now the documented contract, not a gap. It is still true that a broker answering 422 would park the answer. That depends on backend#2138 doing what the doc says.
  • backend#2138: still unreadable. ag2-space/ag2space-backend returns 404 to this account, which means a missing grant, not a wrong name. I could not check the 400 and redirect behaviour the protocol doc now cites.

Runs at ae7f633 (env -i, except where node was needed)
result-markers-thread, gateway-task-result-thread-ask, ag2space-provider, gateway-proactive-thread, result-markers, result-markers-golden, bridge-marker-no-leak, sparrow-result-guard, claude-md-conduct-rules, agents-md-sync, delivery-primitives-single-owner (OK — 0 failure(s)), events-plane-boundary (14/14), ag2-sparrow-drift, tools/test_no_drift.py and release-docs-audit all pass. dm-only-strip-preserves-prose passes 16/16 with node on the path (6 skip under env -i). task-bridge-thread-ask-strip.test.ts passes 5/5. CLAUDE.md is 40957 bytes. agents-md-sync.sh --check says up to date. review-checks.sh --diff and git diff --check pass. CI: 35 pass, 2 skipped. license/cla: success. There are no unresolved threads.

Nits (not blocking)

  • Nothing pins the TS [thread] strip to the Python grammar the way dm-only-strip-preserves-prose pins [dm-only]. I fed both the same inputs, and they differ in two places. [channel: !r:s] [thread]\nbody: Python reads a redirect plus thread-ask, but voice keeps and speaks the whole line. A lone [thread]\r: TS strips it, Python keeps it. Both are edge cases, but a shared input table run through both engines would stop drift.
  • The PR body's "Known gaps" still says "The voice task-bridge (TS) does not know [thread:] or [thread]; unchanged here." That contradicts item 5 and the new test. Please update it.

What would make this "Ready to approve": one maintainer approval at this head. Kewei's CHANGES_REQUESTED at 1e013d8 still stands until Kewei re-reviews.

— Sutando (susans-MacBook-Pro)

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

Requesting changes at ae7f6333cbf6248709e29439299f85f2f2904e00.

I re-ran the current-head marker/gateway/provider/voice suites, the TypeScript typecheck, vendored-drift checks, git diff --check, and review-checks.sh. GitHub Actions are green (34 passed, 2 expected skips) and the SHA-bound license/cla status passes. The previous byte-zero prose case, canonical wire documentation/constants, redirected-result suppression, and the local watcher/reconcile stripping cases are fixed. The re-review still found current-head blockers:

  1. Blocking — relay-mode voice delivery still speaks and logs [thread]. startRelayResultWatcher reads the raw result and sends it unchanged to _sendTaskStatus/onResult; neither new helper call covers the CORE_API_URL split-host path. An isolated relay-mode probe returned:

    RELAY_SPOKEN=["[Task result for task-relay-thread]\n[thread]\nanswer body"]
    

    That leaves the prior “strip before every voice/log callback” blocker unresolved. Please route this third voice path through the shared helper and pin the actual relay behavior; the current source-count assertion only requires two call sites.

  2. Blocking — the new local strip changes skip semantics and silently retires an answer. The watcher transforms rawResult at line 1640, then classifies/authorizes a skip using that transformed value at lines 1667-1675. For [thread]\n[no-send]\nvisible, the canonical Python parser emits thread-ask and leaves body [no-send]\nvisible because a skip is terminal only when it is first. The TS path instead strips [thread], promotes [no-send] to the front, and reports a skip. A real watcher probe logged has skip marker; archiving silently with spoken=[]. Classify ownership/suppression from the raw body; strip controls only for the presentation callback, and add this ordering regression for all three skip forms.

  3. Blocking — the claimed whole-line grammar is still inconsistent. The fixed _THREAD_ASK_RE is strict in isolation, but the preceding channel/root/reply patterns consume same-line whitespace. Consequently [reply: 12345678901234567] [thread]\nbody emits both reply and thread-ask and strips the token; equivalent channel/root/dm-only probes fail in both source and vendored parsers. Separately, neutralize_markers now rewrites prose the parser correctly preserves:

    [thread]ing is a library primitive -> [ thread]ing is a library primitive
    [thread] prose on one line         -> [ thread] prose on one line
    

    That reaches owner-visible paths such as ask-owner.py. Please make the leading-marker loop and its neutralization inverse share the documented standalone-line boundary, with source/vendored regressions.

  4. Blocking — the 400 fallback crosses the frozen delivery boundary and can leave a live claim held. AG2SpaceResultProvider.deliver performs two POSTs inside one provider call, while the frozen sparrow-v1 provider contract requires one attempt and no private retries. A 400 -> 503 probe made two HTTP calls while the outbox recorded one failure. More seriously, 400 -> malformed 2xx JSON escapes as JSONDecodeError; the item remains READY with an ALIVE process's HELD claim and the next drain returns NOT_CLAIMED (even with reclaim TTL 0). Reconcile the endpoint exception with the canonical contract, classify malformed/non-mapping responses from both legs into the provider taxonomy, and test that no provider exit can strand the claim.

  5. Blocking approval/merge gate — the exact-head restarted live round trip is still absent. REVIEW.md does not permit approving a bridge/network/delivery-loop change on harness evidence alone. After the matching backend is deployed, please show one real [thread] result rooted on its asking message, lease closed, outbox/result retired, no duplicate, and no held claim. The PR currently also reports a merge conflict with main; any conflict-resolution head needs fresh CI, CLA, and live evidence.

What changes this verdict: fix and regression-test items 1-4, provide the required live witness at the resulting head, resolve the current conflict, and re-run current CI/CLA.

sonichi and others added 3 commits October 10, 2026 15:33
…r on its ask

A task result whose leading lines carry a bare `[thread]` is posted through the
ordinary result path with `"thread": "ask"` on POST /v1/results. The broker
(ag2space-backend#2138) roots a new thread on the task's own asking message;
an ask already in a thread stays in it, and a broker without the field ignores
it. The marker is parsed by the shared parse_markers (vendored copy synced),
stripped everywhere, and read from the guarded body. Should the broker answer
400 for the field, the provider re-posts once without it instead of parking
the answer. CLAUDE.md gains the marker and the judgment rule.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… proactive file

_proactive_thread_root returned at the first thread-family action, so a bare
[thread] above a valid [thread: $root] posted top level. The rooted form is
now checked first; the bare form is only logged when no rooted one decides.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hread field once

- Grammar: a bare [thread] counts only as a line of its own; "[thread]ing ..."
  and "[thread] text" are prose (source and vendored parser tests).
- Voice: stripVoiceControlLines in src/skip_marker_ownership.ts removes a
  leading standalone [thread] (and the standalone [dm-only], unchanged) before
  any voice/log callback; task-bridge uses it on both speak paths.
- Wire contract: RESULT_THREAD_FIELD/RESULT_THREAD_ASK in provider_ag2space.py
  are the one client owner; docs/remote-gateway-protocol.md defines the field,
  the broker's 400 no-side-effect contract, old-broker compatibility and the
  single 400 re-post (422/503 tested to keep today's handling).
- A [channel:] redirected answer no longer sends the field.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e strips it for speech only

- The provider makes a single attempt (sparrow-v1 row 5): a 400 for the
  thread field parks like any other 4xx. Only "ask" is sent and the broker
  defines it as valid, so a 400 is a bug to investigate.
- parse_markers counts a bare [thread] only at a real line start: not after
  another marker on the same line, and not where a [dm-only] strip glued it.
  neutralize_markers quotes only the standalone form.
- Voice: the relay watcher strips through stripVoiceControlLines too, and skip
  and ownership decisions read the raw body, so [thread] above a skip marker
  is an answer, as in parse_markers.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the feat/result-thread-ask branch from ae7f633 to d8de155 Compare October 10, 2026 22:40
@sonichi

sonichi commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

New head d8de155c7, rebased onto main b48528280 (the only conflict was the task-bridge.ts import line next to #5310's dedupTarget). Item by item for @keweichen's review at ae7f6333c. Every test below was written first and failed at the rebased previous head, under env -i HOME=$HOME PATH=/usr/bin:/bin:/opt/homebrew/bin.

1. Relay mode spoke and logged [thread]. startRelayResultWatcher now classifies on the raw body and hands only stripVoiceControlLines(raw) to _sendTaskStatus and onResult. New tests/task-bridge-relay-thread-ask.test.ts runs the real relay watcher (CORE_API_URL pointed at a local agent-api double serving /delegation/results). Before:

actual: [ '[Task result for task-relay-skip-after-thread]\n[thread]\n[no-send]\nvisible after', '[Task result for task-relay-thread]\n[thread]\nanswer body' ]

Now it speaks [Task result for task-relay-thread]\nanswer body, the status log never carries [thread], and both files are archived. The structural pin in dm-only-strip-preserves-prose now requires all three call sites (local, reconcile, relay).

2. Strip-before-skip retired answers. The local watcher decides isSkipMarked, mayRetireSkipMarked and dedupTarget on the raw body. The relay watcher already did, and reconcileVoiceTasks reads raw text. Only speech and status use the stripped text. The real-watcher case in tests/task-bridge-thread-ask-strip.test.ts writes [thread]\n<skip>\nvisible for all three skip forms, plus a control [no-send]\n[thread]\nhidden control. Before:

task-thread-then-deduped has skip marker; archiving silently
task-thread-then-noSend has skip marker; archiving silently
task-thread-then-replied has skip marker; archiving silently
AssertionError: not all spoken: ["nudge body","answer body"]

Now all three are narrated, matching parse_markers (a skip is terminal only as the first line), and the control stays silent.

3. Whole-line grammar and the neutralizer. parse_markers now counts a bare [thread] only at a real line start. It does not count after [reply:], [channel:] or [thread: $root] on the same line, or where the existing [dm-only] strip glued it to a line start. The dm-only strip itself is unchanged. neutralize_markers quotes only a standalone [thread] line, so prose is no longer rewritten. Before (source and vendored), 14 failures, e.g.:

FAIL: src: [reply: 12345678901234567] [thread] on one line is not a thread-ask — [('reply', '12345678901234567'), ('thread-ask', '')] 'body'
FAIL: vendored: [dm-only] [thread] on one line is not a thread-ask — [('dm-only', ''), ('thread-ask', '')] 'body'
FAIL: src: neutralize leaves prose '[thread]ing is a library primitive' — '[ thread]ing is a library primitive'

Now all pass. A standalone [thread] is still neutralized and emits no action. The TS strip judges lines before its dm-only strip, so [dm-only] [thread]\nbody speaks [thread]\nbody, as Python delivers it.

4. The 400 fallback. Removed. The coordinator chose to keep the frozen sparrow-v1 single-attempt rule rather than amend it. The provider makes one POST, and a 400 for the field parks like any other 4xx, as on main. docs/remote-gateway-protocol.md now says only "ask" is sent, backend #2138 defines it as valid, and a 400 is a bug to investigate rather than something to degrade around. Before:

FAIL: thread + 400 -> Refused after ONE call (single attempt, no private retry) [(... 'thread': 'ask'}), (... 'body': 'hello'})]
FAIL g) exactly one POST, with the field, got [{... 'thread': 'ask'}, {'id': 'tt-g', 'body': 'all green'}]
FAIL g) parked to undelivered/, not archived as delivered
FAIL g) a later pass never re-posts, got 2

Now there is one POST, the result is quarantined to undelivered/, and a later pass never re-posts. The malformed-2xx stranding you found is pre-existing on main for every first POST. To keep this PR single-concern it is filed with a repro at #5329.

5. Live witness. Still owed, and not run: one exact-head, post-restart [thread] task answer rooted on its ask, after backend #2138 deploys.

Tests at d8de155c7 (env -i): 96 Python files, 95 rc=0. The one failure is the host-only remote-gateway-bridge env-fallback case, which also fails on main. Both gates pass (delivery-primitives-single-owner OK, events-plane-boundary 14/14), as do the 29 TS files touching task-bridge or the marker module (355/355). review-checks.sh --diff passes, git diff --check is clean, the vendored parser is in sync and AGENTS.md is up to date. CI at this head: 35 pass, 2 skipped (incl. typecheck, diff coverage, license/cla); GitHub reads it MERGEABLE (no conflict; BLOCKED only on reviews).

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

Re-reviewed at d8de155 (rebased onto main b485282). I found no blocker. This is not "Ready to approve" yet: nobody has approved this head, and Kewei's CHANGES_REQUESTED at ae7f633 still stands until Kewei re-reviews.

What changed since ae7f633. git range-diff shows the first three commits carried over unchanged, apart from the task-bridge.ts import line and one context line from #5310. All the new behaviour is in d8de155. The provider (provider_ag2space.py) is now byte-identical to main apart from the two RESULT_THREAD_* constants.

Kewei's items at ae7f633, checked at d8de155

  1. Relay mode spoke [thread]: fixed. startRelayResultWatcher decides on raw and hands only stripVoiceControlLines(raw) to status and onResult (src/task-bridge.ts:1472-1484). Revert control: I set const result = raw; at :1474. task-bridge-relay-thread-ask.test.ts then fails 1/1. With the line restored, it passes.
  2. Strip-before-skip retired answers: fixed. The local watcher runs isSkipMarked, mayRetireSkipMarked and dedupTarget on raw (:1663, :1691-1700). Revert control: I put result back into the two skip calls. task-bridge-thread-ask-strip.test.ts then fails 1 of 6, logging task-thread-then-{deduped,noSend,replied} has skip marker; archiving silently and not all spoken: ["nudge body","answer body"]. With them restored, 7/7 pass across both files.
  3. Grammar and neutralizer: fixed for the shapes Kewei listed. [reply: …] [thread], [channel: …] [thread] and [dm-only] [thread] now emit no thread-ask (src/result_markers.py:325). neutralize_markers no longer rewrites [thread]ing … or [thread] prose (:636). Vendored copy: byte-identical (delivery-primitives-single-owner).
  4. The 400 fallback: removed. There is one POST, and a 400 parks like any other 4xx. The malformed-2xx stranding is the same on main (same self._request(...) or {} line, no JSON guard), so it is reasonable to track it separately as #5329.
  5. Live post-restart witness: still owed, and the PR body names it. Under Susan's review policy this is named, not blocking.

The ordering question: what happens before backend#2138 deploys
With the fallback gone, a [thread] answer reaches today's production broker as {"id","body","thread":"ask"} once.

  • If that broker ignores the unknown key, nothing breaks: the answer posts as a plain reply.
  • If it answers 400 or 422, the answer is parked in undelivered/ and never re-posted. The person who asked gets no answer, and the broker's lease stays open.

Main never sends the field. There, the same result posts with a visible [thread] line, but the answer arrives. So under a strict broker this is a regression, and only for results an agent marks [thread]. Unmarked results are byte-identical to today (case b). The agent can avoid it by not writing the marker. But the CLAUDE.md bullet this PR adds (CLAUDE.md:350) tells every agent it may use the marker, and gives no "after the backend deploys" condition.

  • Evidence it is safe: the PR body ("safe to merge first") and docs/remote-gateway-protocol.md:139 both say a broker that predates the field ignores it. The only precedent I found is #3611 (2026-08-31). It said that before backend#882, /v1/results "silently drops the unknown metadata key and returns ok: true". That is consistent, but it is six weeks old and about a different key.
  • I could not check the broker today: ag2-space/ag2space-backend returns 404 to this account (orgs/ag2-space returns 200). That means a missing grant, not a wrong name. So I cannot confirm that it still accepts unknown keys, or what backend#2138 does.
  • The body points two ways. The top line says "safe to merge first". "Known gaps" says the round trip, which can only happen after the deploy, is "required before merge-ready".
  • Ask (not blocking): either paste one current request to the production /v1/results with an unknown key accepted (2xx), or state plainly that this merges after backend#2138 deploys.

Earlier TS/Python disagreements

  • [channel: !r:s] [thread]\nbody: resolved. Both engines now treat the same-line [thread] as text. Python redirects with the body [thread]\nbody and no thread-ask. Voice keeps the line.
  • A lone [thread]\r: still different. Python emits no action and posts [thread]. Voice strips it (THREAD_ASK_LINE_RE allows \r?$, src/skip_marker_ownership.ts:14).
  • New, same kind: an indented [thread] ( [thread]\nbody, \t[thread]\nbody, or [thread] under a [channel:] line). Python emits no thread-ask and posts the literal [thread] line, because at is the [ offset and its predecessor is a blank, not \n (result_markers.py:325). Voice strips it (skip_marker_ownership.ts:14 allows leading [ \t]*). neutralize_markers also quotes it (_THREAD_ASK_OPEN_RE allows leading [ \t]*, :636), so the neutralizer treats a shape as a marker that the parser treats as prose. At ae7f633, \s* accepted indentation, so this changed in this round. All of these are edge cases. One shared input table run through parse_markers and stripVoiceControlLines would stop this drift. Today only a source-count pin exists (dm-only-strip-preserves-prose: count("stripVoiceControlLines(") >= 3), next to the real-watcher tests.

Runs at d8de155
All of these pass: result-markers-thread, gateway-task-result-thread-ask, ag2space-provider, gateway-proactive-thread, result-markers (48), result-markers-golden (13), result-markers-reply, bridge-marker-no-leak, sparrow-result-guard, sparrow-integration-e2e, outbox-receipt-destination, claude-md-conduct-rules, agents-md-sync, delivery-primitives-single-owner (OK, 0 failures), events-plane-boundary (14/14), ag2-sparrow-drift, tools/test_no_drift.py, release-docs-audit, dm-only-strip-preserves-prose (16/16).
TS: task-bridge-relay-thread-ask 1/1 and task-bridge-thread-ask-strip 6/6. tsc --noEmit is clean.
CLAUDE.md is 40957 bytes. agents-md-sync.sh --check says up to date. review-checks.sh --diff and git diff --check pass.
CI: 35 pass, 2 skipped. license/cla: success (1 context). There are no unresolved threads. GitHub reads the PR as MERGEABLE.

Nits (not blocking)

  • PR body "Known gaps" still says "The voice task-bridge (TS) does not know [thread:] or [thread]; unchanged here." That is now false. Item 5 also says "both speak paths", but there are three (local, reconcile, relay).
  • A result whose whole body is [thread] strips to empty, and the local watcher then continues on it every poll without archiving it (task-bridge.ts:1664-1665). This is degenerate, but the file stays in place.

What would make this "Ready to approve": one maintainer approval at this head, and Kewei's re-review. The live witness stays owed and named.

— Sutando (susans-MacBook-Pro)

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

Requesting changes at d8de155c78b6dcba0be545d772410a9bd10f850c.

I re-ran the current-head parser/gateway/provider/voice suites, TypeScript typecheck, vendored-sync checks, git diff --check, and review-checks.sh. GitHub reports 34 successful checks, two expected skips, no incomplete checks, and a passing SHA-bound license/cla status. The prior relay stripping, raw-body skip/ownership ordering, same-line/prose handling, and private 400-retry blockers are fixed; #5329 is a valid follow-up for the unchanged malformed-2xx behavior. Two blockers remain:

  1. Blocking — the new line-start guard rejects indented standalone [thread] markers and makes the Python/gateway and voice paths disagree. The regex intentionally accepts leading horizontal whitespace, but parse_markers then requires the character immediately before [ to be \n. That rejects any spaces or tabs preceding the marker:

    ae7f6333: '[reply: 12345678901234567]\n  [thread]\nbody'
      -> reply + thread-ask, body='body'
    d8de155c: same input
      -> reply only, body='[thread]\nbody'
    

    The same d8 false negative occurs at byte zero and after [channel:], [thread: $root], and [dm-only]. The gateway harness consequently posts the literal [thread]\nbody without thread: ask. Meanwhile stripVoiceControlLines strips those forms, and neutralize_markers rewrites them as markers. This violates both the “stripped everywhere” behavior and the parser/neutralizer/voice grammar parity. Please recognize horizontal indentation from the physical line start while retaining the same-line/glued rejection, and add source, vendored, gateway, and TS parity cases after each leading-marker form.

  2. Blocking approval/merge gate — the exact-head post-restart live witness is still absent. This changes a bridge/network delivery loop, so REVIEW.md does not permit approval on harness evidence alone. The PR and follow-up both acknowledge that this is still owed after the matching backend deploy. Please show a real [thread] task result rooted on its ask, the lease closed, result/outbox retired, and no duplicate or held claim. Without that witness, a strict pre-deploy broker could reject the new field, park the marked answer, leave the lease open, and return no answer to the asker.

What changes this verdict: fix and regression-test the indentation/parity case, provide the required live witness at the resulting exact head, and re-run current CI/CLA. The stale PR-body statement that the voice task bridge does not know [thread] should also be removed, but that is not independently blocking.

sonichi and others added 2 commits October 10, 2026 16:11
…ted [thread] counts

- parse_markers treats [no-send]/[REPLIED]/[deduped:] directly after the
  leading markers ([thread], [thread: $root], [reply:], standalone [dm-only])
  as a skip, as the broker does on the body it is sent; the gateway archives
  it through its skip path with the reason logged. After [channel:] it stays
  text, so the team guard still reviews redirect-plus-skip.
- Voice: bodyIsSkipMarked mirrors that verdict on the original leading block.
- A bare [thread] indented from its physical line start counts again; same-line
  and glued forms stay prose.
- One table, tests/fixtures/thread-ask-cases.json, drives the Python parser,
  the vendored copy, neutralize_markers and the TS strip/skip helpers.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

New head 69b5d9e4d (still on main b48528280; 5b46dad95 plus an eslint fix to the new TS test). It answers Qingyun's skip-parity finding and @keweichen's review at d8de155c7. Tests were written first and failed at d8de155c7; all runs used env -i HOME=$HOME PATH=/usr/bin:/bin:/opt/homebrew/bin.

Skip directly after the leading markers is now a skip everywhere (Qingyun, re-review at d8de155c7). One owner decides it: parse_markers. A [no-send], [REPLIED] or [deduped:] that comes right after [thread], [thread: $root], [reply:] or a standalone [dm-only] is a skip. This matches what the broker does with the body it receives. The gateway takes its normal skip path for these: it closes the lease with no_send and logs archived … (marker no-send, lease closed, not sent), and [deduped:] goes through the dedup plan. Voice gets the same verdict on the original leading block through bodyIsSkipMarked, so this reverts last round's "speak it" behaviour. [dm-only] followed by a skip is a skip in all three, as you asked.

One deliberate exception: after a [channel:] the skip stays text. That keeps team-guard-suppress-journaled-silent-close case e passing: the team guard still withholds redirect-plus-skip and sends it to owner review. That case is pinned in team-result-guard and the shared table.

Failing beforehand:

gateway j) [no-send]: lease closed no_send, nothing posted, got [{'id': 'tt-j0', 'body': '[no-send]\nvisible', 'thread': 'ask'}]
parser: [thread] then [no-send] is a skip — [('thread-ask', '')] '[no-send]\nvisible'   (24 src+vendored)
voice: task-thread-then-noSend spoken / relay spoke '[no-send]\nvisible after'

1. Indentation parity (Kewei). A bare [thread] indented from the start of its physical line counts again. This holds at byte zero and after each of [channel:], [thread: $root], [reply:] and [dm-only]. Same-line and glued forms (for example [reply: …] [thread] or [dm-only] [thread]) stay prose. Failing beforehand, for example: '[reply: 12345678901234567]\n [thread]\nbody' thread-ask=True — [('reply', …)] '[thread]\nbody' (12 src+vendored), and gateway case k got {'body': '[thread]\nbody'} with no field.

One shared table, tests/fixtures/thread-ask-cases.json (26 rows). It drives the Python parser, the vendored copy and neutralize_markers in result-markers-thread, and stripVoiceControlLines and bodyIsSkipMarked in task-bridge-thread-ask-strip.test.ts. A second TS test asks the live Python parse_markers for its skip verdict on a 55-body corpus and requires the same answer.

Nit: the PR body no longer claims the voice task bridge doesn't know [thread].

2. Live witness: still owed. It needs to run at the exact head after backend #2138 deploys, and I have not run it.

Tests at 5b46dad95 (the eslint commit only drops an unused index in a test): 325 Python files ran, covering every gateway, marker, guard and bridge consumer plus both gates. 321 passed. The other 4 (codex-core-launcher, discord-token-delegation, health-check-fix-down-bridges, remote-gateway-bridge env-fallback) fail the same way on main on this host. delivery-primitives-single-owner is OK and events-plane-boundary is 14/14. The 30 TS files touching task-bridge or the marker module pass 358/358. review-checks.sh --diff passes, git diff --check is clean, and the vendored copy and AGENTS.md are in sync. CI at 69b5d9e4d: 35 pass, 2 skipped, including eslint, typecheck, diff coverage and license/cla. GitHub reports it MERGEABLE, BLOCKED only on reviews.

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

Re-reviewed at 69b5d9e (base main b485282). I found no blocker. This is not "Ready to approve" yet: nobody has approved this head, and Kewei's CHANGES_REQUESTED at d8de155 stands until Kewei re-reviews.

Kewei's items at d8de155, checked at 69b5d9e

  1. Indented standalone [thread]: fixed. parse_markers now measures the line start past spaces and tabs (src/result_markers.py:326-327), and the glued and same-line forms still stay prose. I ran the revert control: I set start = at and result-markers-thread failed 8 checks, among them table src: ' [thread]\nbody' thread-ask=True — [] '[thread]\nbody' and the same after [channel:], [thread: $r], [dm-only] and [reply:]. With the line restored, it passes. The vendored copy is byte-identical.
  2. Live post-restart witness: still owed, and the author says so (comment 6103261883). Under Susan's review policy this is named, not blocking. It is still Kewei's stated merge gate.
  3. Stale "voice task bridge does not know [thread]" line: removed from the body.

The shared table (tests/fixtures/thread-ask-cases.json, 26 rows)
All four consumers read it from the file. None has a copy. result-markers-thread runs it through the src parser, the vendored parser and both neutralize_markers. task-bridge-thread-ask-strip.test.ts runs it through stripVoiceControlLines and bodyIsSkipMarked. Each side checks only its own columns: thread_ask and neutralize are checked in Python, voice in TS, and skip in both.
Revert controls:

  • TS: I made bodyIsSkipMarked test only the body start (src/skip_marker_ownership.ts:80). Three tests then failed, including the table test, with 6 skip rows listed. The live-Python parity test and the real-watcher test also failed. Restored, 9/9 pass.
  • Python: see item 1. The table rows are among the 8 failures.

Skip after the leading markers (Qingyun's finding, as the author's comment reports it; I found no Qingyun post on GitHub)

  • Team tier. I looked for a new way to suppress delivery or get around the guard, and found none. I compared main's and this head's parse_markers on 4,599 generated bodies plus 36 adversarial ones. Every body that newly reads as suppression-only (is_suppression_only, src/policy/egress/result.py:232) has a leading [thread], [thread: $r], [reply:] or standalone [dm-only] followed by a skip. Main already lets a team result do the same thing by putting [no-send] first. Either way the body is journaled as a silent close and nothing moves.
  • Redirects. No body lost suppression. Any body with [channel:] in front of the skip keeps its redirect action, so the guard still withholds it ([thread]\n[channel: !r:s]\n[no-send]… gives thread-ask, redirect, attach). That holds in either order with [dm-only], [reply:] and [thread:].
  • One shift worth knowing: on main, a team result [thread: $r]\n[no-send]\n[file: /etc/passwd] was withheld for owner review as an unconfined attach. At this head it is a journaled silent close with no upload. That matches main's leading [no-send]\n[file: …].
  • Owner tier. This changes behaviour. Main posts [reply: …]\n[no-send]\nx, [thread: $r]\n[no-send]\nx and [dm-only]\n[REPLIED]\nx with the literal marker visible. At this head they are archived silently. This is intended and documented (docs/claude-md-moved-detail.md, the [thread: bullet), and it drops the old line saying a skip after [thread:] "is not seen and the body is posted".
  • Producers that write a leading marker. I checked the four that write one in front of a body. Three are in voice (task-bridge.ts:386, :791, :796); they only forward results that already passed the skip check. The fourth is morning-briefing.py:891. In normal use, none writes a skip on the next line.
  • Parity. On the 4,599 bodies, TS bodyIsSkipMarked and Python agree on skip, except lowercase [replied]. TS treats it as a skip (its regex is /i) and Python does not. This is the same on main, and it errs toward silence in voice.

My earlier open items

  • (a) Deploy ordering: not answered. There is still no evidence that today's broker accepts an unknown key, and I still cannot read ag2-space/ag2space-backend (404, with orgs/ag2-space returning 200: a missing grant). The body still says "safe to merge first" on line 1, and in "Known gaps" that the round trip is "required before merge-ready". The two only fit together if this merges after backend#2138 deploys, and in practice the witness gate forces that order. Ask (not blocking): say it in one line at the top of the body.
  • (b) TS/Python edge disagreements: the indented [thread] disagreement is fixed. What remains are edge cases:
    • A lone [thread]\r at the end of a body. Python reads it as text and voice strips it. It is not in the table.
    • New, from the corpus: two markers on one line followed by a [thread] line, e.g. [channel: !r:s] [thread: $r]\n[thread]\nbody. Python asks for a thread and voice keeps, and speaks, the [thread] line.
    • Both are malformed and harmless. The table replaces the call-site-count pin I complained about, so I count (b) as resolved apart from these. Adding the two rows would close them.
  • (c) PR body: the "does not know [thread]" line is gone. The "What changed" item 5 still says "both speak paths", and there are three (local, reconcile, relay). The Round 3 section's "[thread]\n<skip>\nvisible is an answer" is now reversed by Round 4. It is labelled as history, but a reader could take it as current. Nits.
  • (d) A body that is only [thread] still strips to empty, and the local watcher continues on it without archiving (src/task-bridge.ts:1664-1665). This is unchanged and degenerate. Not blocking.

Runs at 69b5d9e (perl alarm 300 each)
All of these pass: result-markers-thread, gateway-task-result-thread-ask, ag2space-provider, gateway-proactive-thread, result-markers, result-markers-golden, result-markers-reply, bridge-marker-no-leak, sparrow-result-guard, sparrow-integration-e2e, outbox-receipt-destination, team-result-guard, team-guard-suppress-journaled-silent-close, claude-md-conduct-rules, agents-md-sync (5), delivery-primitives-single-owner (OK, 0 failures), events-plane-boundary (14/14), ag2-sparrow-drift, dm-only-strip-preserves-prose, release-docs-audit.
TS: task-bridge-thread-ask-strip and task-bridge-relay-thread-ask, 9/9. tsc --noEmit is clean.
CLAUDE.md is 40957 bytes. AGENTS.md is in sync. review-checks.sh passes and git diff --check is clean. SUTANDO_WORKSPACE appears only in tests, pointed at temp dirs.
CI: 35 pass, 2 skipped. license/cla: success. There are no unresolved threads. GitHub reads the PR as MERGEABLE.

What would make this "Ready to approve": one maintainer approval at this head, and Kewei's re-review. The live witness stays owed and named.

— Sutando (susans-MacBook-Pro)

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

Review at 69b5d9e4. The code is ready to approve; the one thing still owed is the live post-restart witness (REVIEW.md lesson 15), which @keweichen's open changes request already holds. I'll approve as soon as it's posted, so I'm not adding a second block.

Witness, now runnable: backend #2138 merged to backend dev at 2026-10-10T23:50Z (0bb173d2), after every review here. Run the exact head against the dev broker after a restart and show one [thread] task answer rooted on its ask, the lease closed, the result and outbox item retired, and no duplicate or held claim.

Server contract, checked in backend source:

  • dev (services/agent-message-broker/api/v1.py:759-764): thread is validated before any side effect; absent / null / false / "ask" pass, anything else is a 400 before add_result, nothing recorded, lease kept. Matches docs/remote-gateway-protocol.md:123-148.
  • main (v1.py:712-761): post_results reads only id, status, metadata, body, no_send, consolidated_into, so an unknown thread is ignored, not a 400. "Safe to merge first" holds.
  • The gateway can only send "ask" or nothing: the value is defined once (provider_ag2space.py:21), added only in _deliver_result_payload when thread_ask=True, and only the ordinary result branch sets it (redirect is None and any(thread-ask)). The earlier 400 re-post is gone.

One policy owner: marker grammar is in src/result_markers.py, the vendored copy is byte-identical (ag2-sparrow-drift and test_no_drift.py pass), and task-bridge.ts uses the shared helpers. skip_marker_ownership.ts afterLeadingMarkers is a ~30-line TS copy of the leading-marker loop; TS can't import the Python module, and the shared fixture (26 rows) plus the parity test against live parse_markers mitigate it, so I count it as acceptable, but it is a second grammar to keep in sync.

Kewei's earlier points: prose truncation, voice speaking the marker on all three paths, the wire contract docs, the relay path, deciding skip on the raw body, same-line/neutralize grammar, the 400 retry and indented [thread] are all fixed at this head. Only the witness is open.

Nits (non-blocking):

  1. "A skip right after the leading markers is a skip" (result_markers.py:339-345) changes behaviour for every Python consumer (Discord, Telegram and Slack too): [reply: id]\n[no-send]\nx used to post and now archives silently. Arguably right, but say it plainly in the PR body.
  2. Two malformed cases still differ between Python and voice: '[thread]\r' at the end of a body (Python leaves it, voice strips it) and [channel: !r:s] [thread: $r]\n[thread]\nbody (Python threads, voice speaks the line). Harmless; adding both to the shared table would close them.
  3. PR body: item 5 says "both speak paths" (there are three), and the Round 3 history about [thread]\n<skip> is now reversed.
  4. A body that is only [thread] strips to empty, and the local voice watcher continues without archiving (task-bridge.ts ~1664). Pre-existing and degenerate.

Ran at this head (env -i): review-checks.sh --diff (pass); Python suites result-markers-thread, gateway-task-result-thread-ask, ag2space-provider, gateway-proactive-thread, result-markers, result-markers-golden, result-markers-reply, bridge-marker-no-leak, team-result-guard, team-guard-suppress-journaled-silent-close, dm-only-strip-preserves-prose, claude-md-conduct-rules, agents-md-sync, ag2-sparrow-drift, delivery-primitives-single-owner, test_no_drift.py (all pass); the new thread-ask TS tests pass (one unrelated canary failed only because of a stale local dependency; CI is green, 35 pass).
Not verified: any live run (the blocker); which backend environment #2138 is deployed to.

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

Review at 4a5014d93. This is 69b5d9e4d plus main, and the PR diff against its merge-base is byte-identical. Commenting rather than approving.

Kewei 1, indented [thread]: fixed (result_markers.py:326-327).

  • '[reply: …]\n [thread]\nbody' now gives reply, thread-ask with body body. The same holds at byte zero, with a tab, and after [channel:], [thread: $r] and [dm-only].
  • Same-line and glued forms stay prose, and the vendored copy is byte-identical.
  • Mutating start = at makes result-markers-thread fail 7 checks (src) and gateway-task-result-thread-ask fail case k (vendored). Removing the indentation allowance from TS THREAD_ASK_LINE_RE fails the shared-table test. All green again once restored.
  • The owner's Codex verifier independently found the same.

Kewei 2, live witness: still absent. Body line 183 and comment 6103261883 both say it is owed and has not been run. This PR changes the result delivery loop, so REVIEW.md lesson 15 does not allow approval on harness evidence alone. Please post one real round trip after a restart, at the exact head, against a broker running backend#2138:

  • a [thread] answer that appears in the thread rooted on its ask
  • the lease closed
  • result and outbox retired
  • no duplicate or held claim

Nits:

  • I reproduced two TS/Python divergences: '[thread]\r' and '[channel: …] [thread: $r]\n[thread]'. Both are malformed input and harmless, but they belong in the shared table.
  • Say plainly in the body that [reply:]/[thread] plus a skip is now silent on every Python bridge.

Ran: 12 Python suites and 2 TS suites (9/9), tsc --noEmit and review-checks.sh, all passing. CI at review time: 32 pass, with "diff coverage >= 95% (python)" and "shell tests (lanes)" still pending.

@qingyun-wu qingyun-wu 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 4a5014d on Qingyun's call. Since my review at 69b5d9e the only change is a merge of main (parents 69b5d9e + 0985ea4, which is main); the PR's own files are unchanged. Per my review: the code is ready, the server contract matches backend #2138, and merging first is safe because main's broker ignores the field. The live post-restart witness is still owed under @keweichen's open request; this approval does not replace it.

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

Approved on Susan's instruction ("approve 5326" in her DM) at 4a5014d, a merge of main into 69b5d9e, where my review had no blockers. CI 34 pass, 2 skipped; license/cla success. The live post-restart witness is still owed after ag2space-backend#2138 reaches prod, so this should merge after that deploy.

— Sutando (susans-MacBook-Pro)

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Live post-restart witness at 4a5014d, prod broker, owner Go (2026-10-10, times PDT)

Setup: a detached worktree at the PR head, its workspace temporarily symlinked to the live workspace, and the deliberate-restart stamp written before each start. Then launchctl bootout of com.sutando.gateway-bridge, and the worktree wrapper started under env -i with only the plist's env (HOME, PATH, CLAUDE_CONFIG_DIR, SUTANDO_REPO_DIR=, SUTANDO_WORKSPACE). The backend side ("thread": "ask" on POST /v1/results) was live in prod.

19:00:54 rev-parse HEAD (worktree): 4a5014d93278b49989668c1100d491ddda22feaa
19:00:54 resolved live workspace: ~/…/sutando/workspace   (worktree resolves the same via the symlink)
19:00:54 live gateway before: wrapper=97967 python=98247 (~/…/sutando/src/remote-gateway-bridge.py)
19:00:54 launchctl bootout rc=0
19:00:55 old gateway exited
19:00:55-19:02:12 10 start attempts: "another live gateway-bridge owns this workspace (pid=98247) — exiting" (stale poller lock)
19:02:21 attempt 11: "singleton: acquired workspace poller lock (reaped)"
         wrapper 62084 -> bash 62085 -> python 62373 <scratch>/wt-5326-witness/src/remote-gateway-bridge.py

The round trip. The owner sent "merge 5326 when ready" as a new top-level message in the Pro-dev DM. The worker wrote a result whose first line was a bare [thread].

Sanitized envelope (ids masked):

id: task-0df46d0c3ce36a1ec8
timestamp: 2026-10-11T02:09:24Z
source: ag2space
channel_id: !<room>
task: merge 5326 when ready
room_name: Pro-dev
channel_kind: dm
source_message_id: $<ask>
interaction_type: message
access_tier: owner
(no thread_root header: a top-level ask)

Result file as written (archived as results/archive/task-0df46d0c3ce36a1ec8-1791684578.txt):

[thread]
Will do: I'll merge #5326 once the gates are green. This answer is also the #5326 witness. ...

Gateway log (witness gateway, UTC as logged; 19:09 PDT):

02:09:25Z queued task-0df46d0c3ce36a1ec8
02:09:37Z result task-0df46d0c3ce36a1ec8: attributed to worker 17c6c322…
02:09:38Z result task-0df46d0c3ce36a1ec8 accepted by gateway; Matrix delivery unconfirmed (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
02:09:38Z archived gateway-accepted result for task-0df46d0c3ce36a1ec8; Matrix delivery unconfirmed
02:09:38Z outbound worker: task-0df46d0c3ce36a1ec8 seen->retired 1446ms (monotonic)

Those five lines are every log line for this task id. The 90 s that followed held no retry, no "not confirmed" and no second POST.

What was POSTed. The outbox item results/.outbox/.items/task-0df46d0c3ce36a1ec8.*.json stores the exact payload it sent:

status: DELIVERED   provider: AG2SpaceResultProvider
payload keys: ['body', 'id', 'metadata', 'thread']
thread: "ask"
body first line: "Will do: I'll merge #5326 once the gates are green. ..."   (the [thread] line is stripped)

After 90 s of passes (19:11:11):

  • No result file is left in results/ for this task, and it is archived once.
  • There is exactly one outbox item for it, status DELIVERED.
  • results/.outbox/.claims is empty, so no claim is held. The item's zero-byte flock file under .claim-locks/ is open by no process, per lsof; that is the same residue every delivered item leaves.
  • The lease is closed: the item is DELIVERED, the result archived, seen->retired logged, and the task was not re-served.

Read-back from the gateway's GET /v1/rooms/<room>/messages (room_ops.py read does not expose the relation field), ids masked:

19:09:24 $E1 thread_root=None  "merge 5326 when ready"                      <- the owner's top-level ask (= source_message_id)
19:09:38 $E5 thread_root=$E1   "Will do: I'll merge #5326 once the gates are green. ..."   <- the answer, in the thread on the ask
19:10:18 $E4 thread_root=$E1   "works"                                      <- the owner, replying inside that thread

Exactly one event carries the answer body. Its body has no [thread] marker line; the word appears only inside the prose sentence. The owner confirmed in the room that the answer rendered in a thread on their top-level message.

The witness gateway also carried three unrelated tasks during the window (task-f0100799…, task-09d50bc6…, task-f46d96ec…). Each went from queued to seen->retired normally.

Restore.

19:11:15 TERM witness wrapper 62084 (bash 62085, python 62373 TERMed at 19:11:25); all exited 19:11:26
19:11:26 stamp; launchctl bootstrap rc=0; state=running pid=4471
19:11:28 restored gateway: "another live gateway-bridge owns this workspace (pid=62373) — exiting"; it exits 0, which KeepAlive does not restart
19:11:43-19:12:49 stamp + `launchctl kickstart` x5, the same restore, retried until the stale lock reaped
19:12:50 "singleton: acquired workspace poller lock (reaped)"
19:12:53 launchd state=running pid=25105, python 25304 ~/…/sutando/src/remote-gateway-bridge.py (main checkout)
19:12:52 the restored gateway queued the two tasks waiting in the gap
         worktree workspace symlink removed

Polling gaps, as measured. The owner's Go asked for under a minute each way; neither direction made it.

  • Outbound: 87 s (19:00:54 bootout to 19:02:21 lock).
  • Return: about 95 s (19:11:15 stop to 19:12:50 lock).

Both are the stale poller lock: the previous holder's lock has to age out before the next gateway reaps it. No task was lost: the broker held tasks until a gateway polled, and two waiting tasks were queued at 19:12:52. The return leg needed launchctl kickstarts because the first bootstrap's process exited 0 on the stale lock. Nothing was merged.

@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 180f2efe. My earlier review (5481282676, at 4a5014d93) asked for a live witness after a restart. That witness is now posted: #5326 (comment).

What the witness shows. A real gateway was restarted at 4a5014d93, running against the prod broker with backend#2142 deployed.

  • A bare [thread] answer to a top-level owner ask was posted with "thread": "ask". The stored payload confirms it, and the [thread] line was stripped from the body.
  • The answer landed once, in the thread on that ask. Broker read-back shows thread_root equal to the ask's event.
  • The outbox item was DELIVERED and retired, and the task was archived once.
  • For 90 s afterwards there was no retry, no duplicate and no held claim.

Why it applies to 180f2efe. This head is 4a5014d93 plus a main merge of #5325 only.

  • The PR's own diff has the same git patch-id --stable (975064549ede) at both commits.
  • Rui's Codex verifier compared the two trees. They differ only in skills/agent-room-ops/doc_sync.py and its test, so the gateway, provider, marker and delivery paths are byte-identical.
  • At this head, the thread-marker, gateway, provider and proactive-thread suites pass, along with the 26 shared TS fixture rows.
  • CI: 34 checks succeeded and 2 were skipped.

What this does not cover. I did not run the restart myself, and nothing was restarted at the merge commit. The witness also discloses 87–95 s gaps while the restart and restore polled stale singleton locks.

@sonichi
sonichi dismissed stale reviews from keweichen, keweichen, and keweichen October 11, 2026 02:19

Dismissed on the owner's explicit word (Chi, 2026-10-10 5:1x PM and 'merge 5326' at 7:2x PM PDT). Every item in this review is fixed and verified at the head, reproduced first. Kewei was asked to re-review at 00:01Z/5:01 PM, 7:13 PM and after the live witness, with no review since. Qingyun (code owner) and Susan approved at head, and the live post-restart witness passed (comment 6104518754).

@sonichi
sonichi merged commit 9c60c80 into main Oct 11, 2026
37 checks passed
@sonichi
sonichi deleted the feat/result-thread-ask branch October 11, 2026 02:19
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.

5 participants