Repository navigation
feat(skills): owner-agent-consult — ask a sibling agent of the same owner before answering - #5293
Conversation
95da293 to
7e44e85
Compare
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 91%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/agent-room-ops/mention.pyLines 148-158 148 mxids so the caller disambiguates rather than mentioning the wrong agent.
149 """
150 agent_mxid = agent_mxid or os.environ.get("AGENT_MXID")
151 if not room_id:
! 152 return _result(False, room_id=room_id, reason="room_id required", posted=False)
153 if not handle:
! 154 return _result(False, room_id=room_id, reason="handle required", posted=False)
155
156 # Validated before resolve/gate/network for the same reason as in `say`: a
157 # mention citing the wrong event is worse than one that is refused.
158 try:skills/owner-agent-consult/scripts/consult_policy.pyLines 29-37 29
30 SKILL_DIR = Path(__file__).resolve().parent.parent # lint-workspace-resolution: allow-repo-root
31 _SRC = SKILL_DIR.parent.parent / "src"
32 if _SRC.is_dir() and str(_SRC) not in sys.path:
! 33 sys.path.insert(0, str(_SRC))
34
35 CONFIG_ENABLED = "OWNER_AGENT_CONSULT_ENABLED"
36 CONFIG_ROOM = "OWNER_AGENT_CONSULT_ROOM"
37 CONFIG_ROOM_CLI = "OWNER_AGENT_CONSULT_ROOM_CLI"Lines 145-153 145 return {"ok": False, "reason": f"consult room has {unidentified} member(s) with no identity; "
146 "cannot prove it owner-only"}
147 rows = got.get("members")
148 if not isinstance(rows, list):
! 149 return {"ok": False, "reason": "consult room member list is malformed"}
150 bad = [m for m in rows if not isinstance(m, dict) or not _MXID.match(str(m.get("user_id") or ""))]
151 if bad:
152 return {"ok": False, "reason": f"consult room has {len(bad)} member row(s) with no valid identity; "
153 "cannot prove it owner-only"}Lines 194-202 194
195 def no(reason):
196 return {"ok": False, "reason": reason}
197 if workspace is None:
! 198 return no("workspace unknown")
199 if not ltp.valid_task_id(task_id or ""):
200 return no(f"{task_id!r} is not a task id")
201 tasks = Path(workspace) / "tasks"
202 path = find_task_file(tasks, task_id)Lines 206-217 206 try:
207 with path.open("rb") as fh:
208 raw = fh.read(MAX_TASK_BYTES + 1)
209 text = raw.decode("utf-8")
! 210 except (OSError, UnicodeDecodeError) as e:
! 211 return no(f"task unreadable: {e}")
212 if len(raw) > MAX_TASK_BYTES or "\x00" in text:
! 213 return no("task file is oversized or malformed")
214 verdict = verify_text(text, Path(workspace)).get("verdict")
215 if verdict != "verified":
216 return no(f"task envelope is {verdict}; only a verified envelope establishes its origin")
217 headers = attested_task_headers(text, Path(workspace)).headersLines 215-223 215 if verdict != "verified":
216 return no(f"task envelope is {verdict}; only a verified envelope establishes its origin")
217 headers = attested_task_headers(text, Path(workspace)).headers
218 if headers.get("id") != task_id:
! 219 return no("task does not carry its own id")
220 return {"ok": True, "text": text, "headers": headers, "reason": None}
221
222
223 def trusted_task(task_id: str, workspace: Optional[Path]) -> dict:Lines 296-306 296 """True while `task_id` is live in this inbox with no result."""
297 import local_task_protocol as ltp
298 from task_archive import find_task_file
299 if not task_id or not ltp.valid_task_id(task_id):
! 300 return False
301 if find_task_file(Path(workspace) / "tasks", task_id) is None:
! 302 return False
303 return ltp.find_result(Path(workspace) / "results", task_id) is None
304
305
306 FUTURE_TOLERANCE_S = 300Lines 532-540 532 if not text or not text.strip():
533 return no("empty answer")
534 verdict = guard(room, self_mxid, transport)
535 if not verdict["ok"]:
! 536 return no(verdict["reason"])
537 inc = incoming_ask(transport, room, self_mxid, task_id, workspace, verdict["owner_agents"])
538 if not inc["ok"]:
539 return no(inc["reason"])
540 base = {"answered": True, "to": inc["asker"], "ask_event": inc["ask_event"], "root": inc["ask_event"],Lines 546-554 546 return no("an earlier answer to this ask is unconfirmed; it is not posted again", unconfirmed=True)
547 return {**base, "event_id": held["event_id"], "already_posted": True}
548 if not _claim_file(claim, {"ask_event": inc["ask_event"], "to": inc["asker"], "at": time.time(),
549 "event_id": None, "task_id": task_id}):
! 550 return no("another answer to this ask is being posted")
551 sent = transport.mention(inc["asker"], text.strip(), room, reply_to=inc["ask_event"],
552 thread_root=inc["ask_event"])
553 if _sent_before_publication(sent):
554 claim.unlink(missing_ok=True)Lines 559-568 559 "warning": f"could not confirm the answer was posted ({sent.get('reason') or 'no event id'}); "
560 "it is not posted again"}
561 try:
562 _write_record(claim, {**(_read_record(claim) or {}), "event_id": event})
! 563 except OSError:
! 564 pass
565 return {**base, "event_id": event}
566
567
568 def _consult_of(workspace: Path, msg: dict) -> tuple:Lines 739-747 739 claim = {**binding["record"], "answered_at": time.time(), "answer_event": event,
740 "reply_task": task_id, "payload": binding["payload"]}
741 try:
742 if not _claim_file(dst, claim):
! 743 return _already_answered(workspace, cid, event, task_id, no)
744 except OSError as e:
745 return no(f"consult {cid} could not be closed: {e}")
746 _record_path(workspace, "pending", cid).unlink(missing_ok=True)
747 _record_path(workspace, "nudged", cid).unlink(missing_ok=True)Lines 800-808 800 out[r["cid"]] = {"event_id": held.get("event"), "text": (held.get("payload") or {}).get("reply_text"),
801 "bound": "answer"}
802 continue
803 if err:
! 804 continue
805 cands = [m for m in reversed(messages) if _in_consult(workspace, r, m)
806 and not _key_path(workspace, "bound", m.get("event_id")).exists()]
807 if cands:
808 first = next((m for m in cands if not _window_refusal(r, m, messages, max_duration_s)), cands[0])Lines 817-823 817 inside its thread when it had one."""
818 room = origin.get("channel_id") or origin.get("source_room_id")
819 lead = f"[channel: {room}]\n" if room else ""
820 if origin.get("thread_root"):
! 821 lead += f"[thread: {origin['thread_root']}]\n"
822 return lead |
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed head 029f5c6c0a4352f21a982a1d5faacbf4531f04dc. I checked it and found no blockers. This is a comment and not an approval, because nobody has reviewed this head yet.
Verified
- At head,
tests/skills/owner-agent-consult/consult.test.pygaveRan 36 tests ... OKunderenv -i. The body says 28 because it predates the second commit. - I removed five guards one at a time, and each removal turned the suite red:
- the marker refusal on a consult task failed
test_a_consult_task_is_never_consulted_onward; - the "self not in room" refusal failed
test_guard_edges; - the domain gate failed
test_ask_needs_the_map_to_name_the_domain; - the send-time filter when the ask is out of the read window failed
test_without_the_ask_event_in_window_only_newer_by_time_count; - letting an absent tier through failed
test_a_body_forged_tier_does_not_promoteandtest_non_owner_tiers_are_refused_and_nothing_is_posted.
- the marker refusal on a consult task failed
- I checked the transport against the code it calls.
members.room_members(room, agent),mention.mention(handle, msg, room, agent),read.read_room(room, agent, limit)andresolve.list_agents()match the signatures and return shapes the policy expects. That includeskindon members and newest-firstsender/body/event_id/tson reads. - I read the registry on this host, read-only. Every
/v1/agentsrow carries bothidandowner, which is whatowner_and_agentsrelies on. - The two source-reading tests, marker-defined-once and no-mxid/room/host-path, are structural pins of the kind REVIEW.md #14 allows. They sit beside behavioural tests and do not replace them.
scripts/review-checks.shon the merge-base diff gave PASS.lint-skill --strictreported 0 errors and 0 warnings. No added line matches.sutandoorSUTANDO_WORKSPACE. CI shows 33 passing and 1 skipped (Windows).license/clais success (1 context).- The skill is opt-in and inert without
OWNER_AGENT_CONSULT_ROOM, and nothing in core calls it. So no live round trip is owed before merge. The first live consult is a deploy step, as the body says.
Blockers: none.
Nits (non-blocking)
holds_domainis a case-insensitive substring match, so a short--domainsuch ashostoramatches almost any map entry. Consider matching whole words, or requiring a minimum length.guardchecks room membership once, before the post. A member who joins between the check and thementionwould see the ask. The window is small and the room is owner-designated. If it matters, re-runningguardjust beforewait_for_replyreturns would close it.- The before/after evidence in the body says 28 tests; at this head there are 36.
Still missing: an expert approval at this head. I did not run a live consult.
— Sutando (susans-MacBook-Pro)
keweichen
left a comment
There was a problem hiding this comment.
Reviewed the current head 029f5c6c0a4352f21a982a1d5faacbf4531f04dc. CI/CLA are green, the 36 new tests pass, strict skill lint reports 0 errors/warnings, and the repository review checks pass. I am requesting changes for four blockers:
-
The owner-only task gate is caller-selectable.
consult.py:88-89,109-117accepts arbitrary--task-filecontent, andconsult_policy.py:176-189treats its parsedaccess_tier: owneras authority. Buttask_envelope.py:5-27explicitly says the HMAC is telemetry rather than an authorization boundary, andattested_task_headers()still returns strict headers for unsigned/invalid task-last text. At this head,task_gate("id: forged\naccess_tier: owner\ntask: fabricated\n")returns{"ok": true}. A collaborator task—or a consult trying to hop again—can therefore point this CLI at fabricated/replayed owner text and bypass the two central gates. Bind the capability to the trusted current task and fail closed on unverifiable/replayed input; accepting a caller-chosen path cannot establish owner origin. -
A checkpoint or another consult's reply is returned as the final answer.
consult_policy.py:204-235accepts every nonempty message from the target after the ask and returns on the first poll with a hit. The room reader also drops reply/thread relation metadata. In a deterministic probe, poll 2 contained"On it — checking now."and poll 3 contained"Final answer."; the function returnedanswered: truewith the poll-2 checkpoint and never read poll 3. This is normal behavior, not only hostile input: the task-progress contract emits start/checkpoint messages for long AG2 Space tasks. Parallel asks to one sibling also both consume the same later message. Add a unique consult correlation ID plus an explicit terminal-result signal (or preserve and verify equivalent trusted relation/completion metadata), with regressions for progress-before-final and concurrent asks. -
The implementation violates the repository's skill boundary.
consult.py:24-63modifiessys.pathand imports private implementations from bothagent-room-opsandcollaboration-intelligence.docs/architecture-boundaries.mdexplicitly lists “skill → another skill's private implementation” as disallowed. This also makes the deprecated room-ops fallback—whose ownSKILL.mdsays it must never be the first path—the only transport, and reimplements collaboration-map selection without its freshness/access-scope contract. Route this through a declared/public capability injected at the adapter edge instead. -
The advertised fail-closed audience check can silently lose a member.
agent-room-ops/members.py:71-79drops malformed member rows without auser_idand still returnsok: true;consult_policy.py:111filters malformed rows again. A production-adapter probe with valid self plus one unidentifiable joined row therefore madeguard()returnok: true. If every joined identity cannot be accounted for, the code has not proved the room owner-only and must refuse. Pin that behavior through the production adapter, not only a clean fake transport.
I did not run a live two-agent consult. Once these are fixed, the live network/delivery claim still needs the repository-required real round-trip evidence, including receipt of the correlated final response.
029f5c6 to
c696fc2
Compare
|
@keweichen thanks. All four blockers are fixed at Same probe script, before (old head, rebased) and after ( Before: After (B1 places the same forged text live in a temp workspace's
Regressions in
Other checks, all under Still open, stated plainly:
The PR body is updated to match. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Review at head c696fc2fe (merge-base 1ac89634a, topic diff 9 files +1273/-11). Posted as a COMMENT from the shared qingyun-wu login on behalf of Qingyun (code owner).
Verdict: not ready to merge. Kewei's blockers 2, 3 and 4 are fixed, and I verified each. Blocker 1 is narrowed but not closed (see B1 below). The live consult witness is still owed: the author's own 05:12Z comment says so, and the PR body says it is not a merge gate, so the two disagree.
What I ran (fresh clone, own worktree, env -i):
tests/skills/owner-agent-consult/consult.test.py:Ran 54 tests OK. The room-ops members, read-limit, say-extra-content and mention-fallback suites all exit 0.lint-skill.py --strict: 0 errors, 0 warnings.review-checks.sh --diffon the merge-base diff: PASS.- Mutations (each reverted afterwards) all turn the suite red:
- Drop the answer-tag check in
final_answer:test_concurrent_asks_each_take_only_their_own_answerfails. - Ignore
unidentifiedinguard:test_unidentified_member_refusesandtest_unidentifiable_member_refuses_through_the_real_clifail. The second goes through the realroom_ops.py. - Accept an
unsignedenvelope intrusted_task:test_forged_unsigned_owner_text_is_refusedfails. - Drop the relation check in
final_answer:test_tagged_reply_related_to_another_event_is_not_boundfails.
- Drop the answer-tag check in
Kewei's CHANGES_REQUESTED at 029f5c6c0, checked against this head:
- B2 (a checkpoint is taken as the final answer): fixed. The ask carries
consult:<cid>.final_answer(consult_policy.py:265-277) accepts only a first line equal toanswer_tag(cid), and only when anyin_reply_to/thread_rootis the ask.read.py_relationnow keeps those fields. - B3 (the skill imports another skill's internals): fixed.
consult.pyimports onlysrc/(workspace_default) and runs the configured room CLI as a subprocess. A grep of the skill's scripts foragent-room-ops/collaboration-intelligenceimports finds nothing.room_ops.pygains a publicagentsverb. - B4 (a member is silently dropped): fixed.
members.pycounts id-less rows asunidentified.guardrefuses when that count is non-zero or missing, and on any malformed row. - B1 (the owner gate is caller-selectable): narrowed, not closed.
--task-fileis gone, andtrusted_task(consult_policy.py:174-213) requires a live, envelope-verified, unanswered owner task in this workspace'stasks/. Forged or replayed text is now refused. But Kewei asked to bind the capability to the trusted current task, and the gate accepts any live owner task id. While an owner task is queued behind the one being processed, a collaborator task (collaborators are not sandboxed), or a prompt-injected one, can runconsult.py ask --task-id <that queued owner task>. Every gate then passes, and the sibling's answer goes back into the non-owner context. That is the flow this skill exists to prevent. The O_EXCL ledger then also spends the real owner task's one consult. Ask: bind the gate to the task this session has actually claimed, for example through the agent-activityprocessingclaim. Or else document why a queued owner task cannot be named. I have not built this attack end to end. It comes from reading the code: nothing intrusted_taskorconsult.pyrelatestask_idto the running task.
Nits (non-blocking):
READ_LIMIT = 30: if more than 30 messages land after the ask before the next poll, the final answer can scroll out of the window and the consult times out. That is unlikely in an owner-only room, but the timeout reason should say so instead of "no final answer".- The body's "Live path?" section says the first live consult is a deploy step, not a merge gate. The author's comment says the live receipt "is still owed before merge". Pick one. Kewei's re-review condition asks for it before approval.
Worst-case disruption: none by default. Without OWNER_AGENT_CONSULT_ROOM and _ROOM_CLI the skill is inert, and nothing in core calls it. The agent-room-ops changes are additive: a new verb and new optional response fields. The worst configured case is the B1 information flow above.
Not verified: a live two-agent consult, the production gateway's /v1/agents row shape on other hosts, and the B1 path end to end.
c696fc2 to
e417067
Compare
|
@keweichen @qingyun-wu @john-the-dev I redesigned this at 1. Notification instead of a bounded wait.
2. No "once per pair". A follow-up to the same agent within the same task is posted as a reply to the first ask. Its answer may thread under the first ask. The one-hop rule, the verified-owner-task gate and the owner-only room guard are unchanged. SKILL.md says a per-task budget is the guard to add if repeated asks become a problem. None is added. Evidence. The new test file at Open review items
Still not done: a live two-agent consult. |
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed head e4170670e19725947375098987fcfc0929c18bf8 (the redesign, rebased on b48528280). I found no blocker of my own. This is a comment and not an approval for two reasons. No maintainer has approved this head. And @keweichen's CHANGES_REQUESTED from 029f5c6c still stands.
Verified at this head
tests/skills/owner-agent-consult/consult.test.pyunderenv -i:Ran 67 tests ... OKin 2.4 s, which matches the body.- Revert controls. Each was applied alone and reverted afterwards, and each turned the suite red:
- sender check off (
consult_policy.py:406-407):test_an_answer_from_another_sender_does_not_matchfails; - reply-to/thread check off (
:411-414):test_not_a_reply_to_the_ask_does_not_matchandtest_a_follow_up_answer_cannot_cite_another_consults_askfail; - the atomic claim (
:421) replaced by a copy: 3 tests fail, including the "already answered" one; guardignoringunidentified: 2 tests fail, one of them through the realroom_ops.py.
- sender check off (
- I probed the redesign directly against the policy:
- a third agent of the same owner answering with the sibling's consult id is refused (
answer is from …, not …); - an unknown consult id is refused (
consult … is unknown); - a second match after close is refused (
already answered); - 20 threads matching the same answer from 20 different reply tasks gave exactly 1 match;
- a reply task cannot consult onward.
trusted_taskrefuses it on tier (team), and it also carries the marker. So one hop is enforced in code, not only in the SKILL.md text.
- a third agent of the same owner answering with the sibling's consult id is refused (
- The skill imports nothing from another skill; the transport is the room CLI run as a subprocess. Core does not name the skill.
review-checks.sh --diff: PASS.lint-skill --strict: 0 errors, 0 warnings. The only added line matching/Users/is a test fixture string (/Users/someone/x) in the no-host-path test.- The neighbouring room-ops suites exit 0, except
agent-room-ops-read-redaction.test.py. That one fails identically atmain:detect-secretsis missing underenv -ion this host, so it is not this PR. - CI: 33 pass, 1 skipped (Windows).
license/clais success (1 context).
Prior review items at this head
- @keweichen B1 (caller-selectable owner gate):
- Forged and replayed text: fixed.
live_taskrequires a verified envelope and a live task with no result. - Binding to the current task: not fixed, and the author says so. See the next item.
- Forged and replayed text: fixed.
- @qingyun-wu B1 (any live owner task id can be named): not fixed.
- The redesign removes the return path:
askreturns only{asked, cid, ask_event}. - A non-owner session can still post an ask under a queued owner task, and nothing stops non-owner content going into it. SKILL.md rule 1 is prose only.
- That said, any session with shell access can already reach the same room through the room CLI. So binding the task id in
consult.pywould not by itself create a boundary. That is for Kewei and Qingyun to decide; I don't block on it.
- The redesign removes the return path:
- @keweichen B2–B4: still fixed. I re-checked B2 (the tag and relation checks above), B3 (no cross-skill imports) and B4 (the
unidentifiedmutation above). - @qingyun-wu nits:
READ_LIMIT: fixed. It now reports "not in the last 30 messages".- Live-witness wording: fixed. The body now says the witness is owed before approval.
Should fix (not blocking)
mark_nudgedcan bring a closed consult back to pending (consult_policy.py:282-291against:417-421).mark_nudgedreads the pending record and then rewrites it withos.replace. Ifmatchrenames the record toanswered/between that read and that write, the write puts a pending copy back. A later duplicate reply task then matches a second time. I reproduced this by interleaving the two steps by hand:pending after: ['0123…'], thenduplicate matches again: True. This breaks the body's claim that "a duplicate reply task cannot answer twice" when a nudge pass and a reply land together. Possible fix: havemark_nudgedwrite its update through a rename-based claim, or havematchrefuse whenanswered/<cid>already exists.matchdoes not check that the reply's room is the room the consult was asked in (:387checks only against the configured room).--roomcan override that per call. With--room !other, an answer from the sibling in a room the guard never checked still matches. The sender check still holds. Comparingrec["room"]with the reply task's room would close this.- Pending records never expire, and
answered/is never pruned (:270-279). A late answer weeks later still reaches the owner. I probed a 30-day-old consult and it is still pending and still matchable. Consider an expiry after whichmatchrefuses loudly, plus some retention foranswered/. - The reply task's tier. The sibling's answer arrives as a Team-tier task (the test fixture uses
access_tier: team). Underdocs/access-control.md, AG2 Space Team without the Collaborator stamp takes the restricted path. It is not clear that path can runconsult.py matchor write the owner-bound proactive result with[channel:]. SKILL.md should say what room access the consult room needs. The live round trip will show whether this works.
Nits
- A reply with no relation at all, tagged and from the right sender, matches. That is what the docs say ("when present"), but the binding then rests on the consult id plus the sender.
askstill checks membership once, before the post (from my earlier review);matchdoesn't re-runguard.
Still missing
- A maintainer approval at this head.
- @keweichen to re-review or dismiss the CR at
029f5c6c. - A live two-agent consult with a correlated final answer received, which the body itself says is owed. That is named, not blocking.
Not verified
- Any live consult.
- How the Team-tier reply task is actually routed (item 4).
- The race in item 1 under real concurrency; I reproduced it by interleaving the steps by hand.
— Sutando (susans-MacBook-Pro)
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed e4170670e. Changes requested.
What I verified:
- 67 tests OK under
env -i,review-checks.shPASS, and nosrc/change or cross-skill import. - Kewei B2–B4 stay fixed (
consult_policy.py:362-372, 401,consult.py:39-62,:127-139). - I ran falsifying probes on every guard. Each was refused with nothing posted:
- the one-hop rule
- non-owner tier
- collaborator
- stranger, foreign and unidentified members
- wrong sender, relation, cid and room
- Disabling each guard turned the suite red, except the
ask_eventcheck at:409-410, which no test pins.
Blockers:
- Post-then-record gap (
:351-357). If the second_write_recordraises an OSError, it propagates uncaught after the sibling already has the question. The correctly threaded reply is then refused:matched=False reason='the ask was never confirmed posted'. A crash between the post and the write leaves the same state. Make this recoverable, and pin the post-success/write-failure and restart schedule with a test. - B1 is still open. An
asknaming another queued owner task returnsasked=True, becausetrusted_task(:213-234) never ties the task id to the one this session is actually running. - No live witness yet (REVIEW.md lesson 15, and Kewei's re-review condition). A real two-agent ask → reply task →
matchround trip has not been run.
Should fix: mark_nudged (:282-290) can recreate the pending record after match_reply's rename (:421). If a match lands during a nudge, the same answer then matches a second time (second match ... matched=True). Serialize the transitions, or refuse to recreate the record once answered/<cid> exists, and add the interleaving as a regression test.
The owner's Codex verifier independently reproduced both defects at this head and returned FAIL.
Nits:
- A reply with no relation still matches (
:411-414). Either require one, or say so in SKILL.md. test_wrong_consult_id_does_not_matchstill passes when cid binding is removed. It only passes because the rename raises FileNotFoundError.
8d267c3 to
84c0f99
Compare
|
@keweichen @qingyun-wu @liususan091219 @john-the-dev This is a second redesign, at What changed since
Does onward consulting reopen the gate hole @qingyun-wu noted? An onward ask (and an
There is a refusal test for each of these:
So an onward ask adds no way in. The residual on origin asks is unchanged and stated in the body: a session on an agent can still start an origin ask under a queued owner task, and the answer goes to that owner. Evidence. The new test file at The first push ( Still not done: a live consult. It needs the owner-only room with the owner's agents in it, and @keweichen's live-receipt condition still stands. |
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed head 84c0f9903db393ad97591887c9e6dd01b1ed7d05 (the second redesign, on 0985ea4ba). Changes requested for two things below. The chain trace and loop guard otherwise hold up well under probing.
Verified at this head
tests/skills/owner-agent-consult/consult.test.pyunderenv -i:Ran 90 tests ... OK. Everytests/room-ops-*andtests/agent-room-ops-*suite exits 0.review-checks.sh --diff: PASS. The only added line matching/Users/is the test fixture/Users/someone/x. There is nosrc/change against main.- Revert controls. Each was applied alone and reverted afterwards:
- root check off (
consult_policy.py:428):test_a_root_that_is_not_a_first_ask_is_refusedgoes red; - link trace off (
:432):test_a_chain_with_a_hop_the_thread_does_not_show_is_refusedgoes red; loop_checkoff (:385): two tests go red;- the "addressed to me by the previous agent" check off (
:416): one test goes red; - the
sender == chain[-2]filter inasks_inoff (:375): suite stays green. No test pins it (nit 1).
- root check off (
- Probes of the trace. Each of these was refused with nothing posted:
- a fabricated root id (
not in the last 100 messages); - a via task from another room (
task did not come from the consult room); - a root ask posted by the owner's human account (
not addressed to this agent by one of the owner's agents); - a forged reverse ask
B>Ain the thread, used by A to consult onward (the thread root is not a consult ask by this chain's first agent).
Sender,thread_rootand event ids come from the room read (read.py_relation). The task's own headers come from a verified envelope (live_task). So the trace rests on room data, not on task-body text.
- a fabricated root id (
- Loop guard. A→B→A cannot happen:
parse_askrejects a chain that repeats an agent (:330), andloop_checkrefuses any agent seen in any chain in the thread. A different consult id does not help: an onward ask must trace to the same root. Starting a new root needs an owner-tier task, and a reply task isteam. - Pagination does not truncate silently.
thread_viewrefuses when the root is outside the last 100 messages (:362-365). Because the window is newest-first, a visible root means the whole thread is visible. - CI: 33 pass, 1 skipped (Windows).
license/clasuccess (1 context). 0 unresolved threads.
Blockers
- The root trace does not show that the gate ran, and the PR says it does. The body (§"The trace…") and SKILL.md:106-107 say the root "is a first ask by the chain's first agent. Only
ask --task-idposts one, behind the verified-owner-task gate". The body concludes "Onward asks add no new way in." The code checks only that the root is a well-formed first ask sent by one of the owner's agents (:427-429). It cannot tell whetherconsult.pyposted it.
Probe: A posts the first-ask marker by hand, with no owner task and no pending record. B then receives it as a task.ask --via-taskfrom B givesasked: True, andanswer --task-idgivesanswered: True.
So any session on an owner's agent that can run the room CLI can start a full chain, and the siblings' answers land in the owner-only room it can read. It does not need a queued owner task. That is wider than the residual the body names ("under a queued owner task").
Ask: state what the trace proves, which is "posted by one of the owner's agents", and name this residual in SKILL.md and the body. Or bind the root to something only the gate produces. Chi's no-hop-limit decision is not in question. mark_nudgedcan still bring a closed consult back, so a duplicate reply matches twice (:282-291against:601-607). This is my item 1 frome4170670, and @john-the-dev reproduced it there too. The code is unchanged at this head. I reproduced it again by interleaving the steps: first matchTrue, then a duplicate reply after the nudge writeTrue. The body still says the rename means "a duplicate reply task cannot answer twice". Ask: havemark_nudgedrefuse whenanswered/<cid>exists, or route both through one rename-based claim. Add the interleaving as a test.
Prior review items at this head
- My 4 should-fix items from
e4170670:- (1)
mark_nudgedresurrection: not fixed. It is now blocker 2. - (2)
matchchecks only the configured/--roomroom (:567), neverrec["room"]: not fixed. Probe:match --room !other, with a sibling's answer in that room claimingthread_root: $ask, givesmatched: True. The record's room is!consult. - (3) No expiry: not fixed. A consult asked 20,000+ days ago is still pending and still matches.
answered/is never pruned. - (4) The Team-tier reply/ask task's routing and the room access it needs: not specified. SKILL.md:86 and :116 say only that the task "arrives". This now covers onward asks too.
- (1)
- @keweichen B1 / @qingyun-wu's gate hole: still open for origin asks.
trusted_task(:213-234) still does not bind the id to the task this session is running. Blocker 1 widens it to onward chains. @keweichen's CR at029f5c6cstands. - @john-the-dev's CR at
e4170670:- Post-then-record gap: not fixed. The second
_write_record(:511-512) still runs uncaught after the post. A failure there leaves a posted ask whose answer is refused (the ask was never confirmed posted). - B1: as above.
- Resurrection: blocker 2.
- Nit "the
ask_eventcheck at:592-593is unpinned": not re-checked by me.
- Post-then-record gap: not fixed. The second
Should fix (not blocking)
- No bound on cost; the bound on depth is only implicit. Chi decided there is no hop limit (author comment 6103502305), so I note this rather than block on it.
- Depth is bounded by the number of the owner's agents in the room (chains are distinct). Each agent is asked at most once per thread.
- Follow-ups are not limited at all: 25 of 25 follow-ups
A→Bfrom one owner task were posted, and 25 of 25B→Cfrom one ask task. - Two agents that read the thread at the same time can both ask the same agent. In a stale-read probe, both were asked.
- A thread longer than 100 messages fails closed: no further onward ask, and
matchrefuses an answer outside the window, so that consult can only time out. - Runaway LLM follow-ups are the realistic cost risk. A per-task or per-thread cap would be cheap.
- Where the final answer goes. Onward context stays in the owner-only room:
guardruns before everyaskandanswerpost (:465,:531). Butreply_lead(:624-631) sends the relayed answer to the original task's conversation. When the owner asked in a shared room, a sibling's answer drawn from the owner's accounts lands in that room. SKILL.md:122-125 should apply the data-origin rule: such data goes to the owner's DM.
Nits
- Pin the
sender == chain[-2]filter inasks_in(:375); disabling it leaves the suite green. - A reply with no relation still matches. That is documented as "when present".
Still missing
- A maintainer approval at this head. @keweichen's CR (
029f5c6c) and @john-the-dev's CR (e4170670) are both still standing. - A live consult with a correlated final answer, which the body says is owed. It is named, not blocking.
Not verified
- Any live consult, onward or not.
- How Team-tier ask and answer tasks are actually routed on a receiving agent.
- The races (blocker 2, and the fan-in race) under real concurrency. I reproduced both by interleaving or a stale read.
— Sutando (susans-MacBook-Pro)
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed 84c0f9903. Changes requested.
What I verified:
- 90 tests OK under
env -i, and review-checks PASS. - The loop guard refuses A→B→A and A→B→C→A, and refuses an agent already reached through another path.
- The trace refuses a mismatched thread root, a hop the thread doesn't show, non-agent senders and non-owner origins.
- 24 of 27 guard mutations turn the suite red.
Blockers:
- An ungated root is accepted.
incoming_ask(:391-437) checks the thread's shape, not that the owner-task gate ran. A root posted directly as A's room identity, with no owner task, lets B consult onward and answer:onward ask from an UNGATED root: asked=True. With no hop limit, this fans out to all of the owner's agents. Either bind the root to something B can verify, or state plainly that trust equals the weakest session holding any owner-agent credential, and get the owner's sign-off on that. The owner's Codex verifier reproduced this independently. - Post-then-record is still unfixed (
:500-512). A failed write after posting still raises, and the threaded answer is then refused with "the ask was never confirmed posted". - B1 is still open. Neither
--task-idnor--via-taskis bound to the calling task, and--via-taskalso accepts a task that already has a result. - The live round trip is still owed (REVIEW.md lesson 15).
Should fix: mark_nudged still races the rename at :605. I reproduced it again: the second match of the same answer returns matched=True.
Nit: the root chain[0] check at :428 is untested. Removing it makes a chain C>B inside A's thread get accepted.
84c0f99 to
9741f6c
Compare
|
@liususan091219 @john-the-dev thank you both. @keweichen @qingyun-wu for your re-review too. Round 4 is at Blockers
Susan's should-fix items
Nits
Evidence.
|
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Re-reviewed e626571 (incremental from 5049a26: consult_policy.py, SKILL.md, consult.test.py).
- Kewei's 5485264932 blocker is fixed. pending() now skips the prune when a message is bound as the answer (
held = _answer_bindings(...), consult_policy.py:325-326, guard at :337-338). Production-policy probe of bind → close crash → nudge → owner task result → pending at +30d and again at +31d:
e626571: rows=[(C1, expired=True)] both passes; waiting_answers shows E with bound="answer";--event-id E --finalmatched; listed as closed_unrelayed untilrelayed, then [].
control 5049a26: rows=[], waiting={}. Control 2: the same schedule with no binding is still pruned at both heads. - No regression on our earlier probe (close by task → same-task retry → task result → still listed). At head: retry closed_unrelayed=True, still listed after the task result, cleared only by
relayed. - closed_unrelayed now reaches only the route that closed the consult (
reply_task == task_id, :756). Event-closed: task route None, event route True. Task-closed: event route None, task route True. At 5049a26 both routes got True. The other route still sees it through pending()'s closed_unrelayed row (:348-353). - Our wording nit is fixed: "tell the owner once" is gone from :352 and :757-759; SKILL.md:144-148 adds at-least-once, the false-alarm case, and route scoping.
- Mutation checks: dropping the held guard turns test_kewei_a_* red; dropping the reply_task scope turns test_qingyun_* and the reworked returned-once test red.
- Nit (test gap, non-blocking): removing the
disposition == "answer"filter in _answer_bindings (:770) leaves all 114 tests green. Under that mutation a consult with only a--not-finalbinding is never pruned past expiry (probe: rows=[C1]; at head: []). One test pinning that prune would keep "held" from growing past answer bindings. - Growth: at most one held row per consult, listed every pass, and
--event-id --finalalways closes it from the binding payload. Nothing caps a row whose close keeps failing; acceptable. - Optional: SKILL.md:160 still says "tell the owner once" for the nudge notice; the same crash between telling the owner and
--nudgedmakes that at-least-once too. - Verified locally under env -i: consult.test.py 114 OK, room-ops-relations OK, lint-skill --strict 0/0, git diff --check clean, review-checks.sh --diff PASS on cumulative and incremental diffs.
- CI at submit: 21 pass, 10 pending, 0 failed; license/cla success on the status endpoint.
- Still standing at 5049a26: CHANGES_REQUESTED from keweichen (5485264932, now fixed — needs his re-review) and john-the-dev (5485270409). Still owed (named, not blocking): a live post-restart ask → answer → relay round trip at this head.
— Sutando (susans-MacBook-Pro)
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed e626571d6, which is one commit on top of 5049a26ad (+50/−21). Kewei's expiry-prune blocker (5485264932) is fixed, and the closer-only change doesn't reopen Susan's case. Changes requested, for the live round trip only.
Verified at this head
-
Tests: in a fresh clone at
e626571d6, underenv -u SUTANDO_WORKSPACE_DIR -i:consult.test.pygivesRan 114 tests ... OK, androom-ops-relations.test.pygivesRan 21 tests ... OK.review-checks.sh --diffon the fullgh pr diff 5293(2999 lines) passes, andgit diff --checkis clean. -
The new tests discriminate. I ran this head's test file against
5049a26ad's code and gotFAILED (failures=3):test_kewei_a_bound_answer_survives_nudge_task_completion_and_expiryfails with[] != [(C1, True)];test_qingyun_an_event_close_is_not_reported_to_that_events_reply_taskfails;test_the_answer_is_returned_once_by_either_routefails.
-
Kewei's sequence, my
probe_r9b. I injected anOSErrorso the bound--finalclose fails, then ranmark_nudged, gave the owner task a result, and ranpending(now + 8 days). The record is still listed, andwaiting_answersstill shows{C1: {$ask2, bound: answer}}.--event-id --finalthen closes it. At5049a26adthat same run gavepending=[]andwaiting_answers={}. The fix (consult_policy.py,pending) skips the prune when_answer_bindingsholds the cid. -
Closer-only
closed_unrelayeddoesn't reopen Susan's case (myprobe_r10,probe_r9,probe_r8b):- Task close, crash. A retry of the same task gets
closed_unrelayed=Truewith the "tell the owner" instruction. Its result doesn't clear the row; onlyrelayeddoes. - Event close, crash. A retry of the same event gets
closed_unrelayed=True. - Non-closers. The event's reply task after an event close, the event route after a task close, and a re-emitted task under a new id each get a plain "already answered". In every case
pendingstill listsclosed_unrelayedwith theanswer_event, including after the non-closer's own result, untilrelayed.
So a non-closer is never the only signal. The
pendingpass reports it either way, and nothing silently clears it. That matches SKILL.md, which now notes the possible false-alarm notice. - Task close, crash. A retry of the same task gets
-
Wording. "tell the owner once" is gone from both
closed_unrelayedmessages. The one remaining instance (SKILL.md:160) is thenudge_duenotice, whichmark_nudgedmakes once, so it's correct. -
No regressions (
probe_r4b,probe_r4,probe_r5,probe_r6,probe_r8re-run):- the deadlock fix (C1 and C2 both close);
- K1 (15 of 15 hostile marker payloads yield only the intended redirect);
- K2–K6;
- Kewei's first five;
- the neutralized preview;
- bound rows while the room read is down;
- exactly one delivery in every crash sequence, followed by
closed_unrelayeduntilrelayed.
No replay code is left.
CI is complete at this head: 32 checks succeeded and 1 was skipped (PowerShell + Windows runtime), with none failed. shell tests (lanes) is success.
Still owed
The live witness at this head. The PR body says "A live witness at e626571d6 is still owed … Nothing was run live".
Not verified by me: any live gateway behaviour, and the 32 mutation controls the author lists.
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed at e626571 (diff since 5049a26: 3 files, +50/−21). The code is approvable. This is a COMMENT only because the live witness at this head and the owner's sign-off are still open, and two CI jobs were running when I checked.
Verified:
- Kewei's expiry blocker is fixed and pinned.
pending()keeps an expired record while an answer is bound and the close hasn't landed. Against 5049a26'sconsult_policy.py,test_kewei_a_bound_answer_survives_nudge_task_completion_and_expiryfails; at this head it passes. Removing only the new keep-check fails exactly that test. The way out ismatch --event-id <E> --final, which closes from the binding even after expiry; the record then becomesclosed_unrelayeduntilrelayed. The owner isn't re-nudged, becausenudge_duerequires not yet nudged. closed_unrelayedis now scoped to the closing route.- My original silent-loss probe still gets
closed_unrelayedon the retry of the same task, and the row stays listed after the result. - The spurious-notice case (an
--event-idclose, then that event's reply task) now gets a plain "already answered". - An
--event-idclose followed by its own retry still getsclosed_unrelayed. - A retry through the other route isn't told, but
pendingstill lists the consult untilrelayed, so nothing is lost. Nothing re-delivers, andreply_textnever appears on a refusal. - Removing the same-route check fails 3 tests.
- My original silent-loss probe still gets
- Wording: the
pendingreason (:352) and_already_answeredno longer say "once". SKILL.md:144-148 covers at-least-once delivery, the possible false alarm, and route scoping; :124-125 says bound consults outlive expiry. - Tests and checks:
consult.test.py114 OK underenv -i; neighbours OK;review-checks.sh --diffandgit diff --checkclean; CI 29 success, 1 skipped, 2 still running; CLA green.
Nit: a corrupt answer binding is still listed, but --final refuses it as malformed, so that consult would stay listed forever with no documented way out. It needs a corrupted state file, so it's not blocking.
Still open: the live witness at this head (Rui's and Kewei's condition); Rui's verifier and Kewei's re-review at this head; the owner's recorded sign-off on extending option (b) to the relay step. Not verified: the author's 32 mutation controls (I re-ran only my own mutations); a live round trip.
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact current head e626571d6165fb6284627a6b1b26cc3eda0eacd9. Changes requested; not merge-ready.
The parent-head blocker is fixed for the serial schedule: a bound, unclosed answer now remains listed past expiry, and the new regression fails against 5049a26ad. The route-specific closed_unrelayed behavior also works as documented. One concurrency schedule still recreates the same durable-answer discovery loss.
Blocker — expiry pruning races the answer-binding claim
pending() snapshots both open_recs and _answer_bindings() before deciding which expired rows to unlink. Independently, match_reply() samples the expiry before it claims bound/<event>. Those two operations are not coordinated.
Deterministic exact-head production-policy interleaving:
- A nudged consult's owner task is complete.
match --event-id E --finalsamples time exactly at the expiry boundary, so the strict> expire_after_scheck passes, then pauses before claiming the binding. pending()runs just after expiry, reads the pending record, scansbound/, and sees no answer binding yet.matchresumes, atomically creates the answer binding, then an injected failure creatinganswered/<cid>leaves the close open.pending()resumes with its stale emptyheldsnapshot and deletespending/<cid>andnudged/<cid>.
Observed:
match_at_boundary = matched:false, "could not be closed"
bound_after_snapshot = true
pending_rows_after_prune = []
pending_exists = false
bound_exists = true
waiting_answers = {}
The payload remains in bound/, and a caller that remembers E can still close it, but after restart the supported pending / answer_waiting workflow cannot discover E. This recreates the defect this commit intends to fix. The new test is sequential—its binding exists before pending() scans—so it does not cover this interleaving.
Please coordinate the binding/prune writers or enumerate durable answer bindings independently of pending records, and pin the boundary interleaving with a deterministic barrier. A second pre-unlink lookup alone still has the same check/unlink race.
Required live evidence is still absent
The PR body explicitly says no exact-head live witness was run. REVIEW.md criterion 15 still requires the real post-restart ask → answer → correlated relay witness through the configured room/task-delivery path before approval.
Verification at this head: consult.test.py 114/114, room relations 21/21, room-ops 173/173, strict skill lint, cumulative review-checks.sh, git diff --check, and py_compile pass locally. Both new tests were collected by name and fail against parent 5049a26ad. Exact-head CI is 32 successful checks plus one skipped Windows job; license/cla is green.
An expiry pass that scanned for answer bindings, then lost the race to a match that bound an answer and failed to close, deleted the pending record from its stale snapshot and orphaned the binding. pending is now read-only: an expired, nudged consult whose task is done is hidden from the listing at read time, never removed, so no interleaving can orphan a record. SKILL.md states the storage cost, which is the same kind as the claims. Tests pin the race with a deterministic pause after the scan, and that a progress binding is never shown as the answer. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@keweichen thanks. Fixed at
It fails against @liususan091219: added @qingyun-wu: no change to your items in this push. Under |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed at 05e8c0f. The code is approvable. This is a COMMENT for the same non-code gates as before, plus CI that was still running when I checked.
Verified:
- Kewei's race is fixed and pinned. Running this head's tests against e626571's
consult_policy.py,test_kewei_a_match_racing_the_expiry_pass_orphans_nothingfails ([] != ['1111111111111111']): the stalepending()pass deleted the record while its answer binding existed. At this head it passes. Putting the old delete back on the hidden branch fails it andtest_an_expired_consult_is_refused_listed_then_hidden_never_deleted. - Deletes and readers:
- Only the close step deletes now, after the answered record exists, plus releasing an unposted ask's own record.
matchstill refuses a new binding after expiry, and an answer bound before expiry still closes.waiting_answersresults are attached only to rowspending()lists, so a hidden consult never shows an answer waiting.- Nudge behaviour is unchanged.
relayedonly reads answered records. - I found no path where a hidden consult wrongly accepts or refuses a match.
- Earlier properties still hold (my probes re-run at this head): silent loss stays closed;
closed_unrelayedgoes only to the closing route; delivery is at most once. - Susan's binding-filter test is real: dropping
disposition == "answer"from_answer_bindingsfails onlytest_susan_a_progress_binding_is_not_shown_as_the_answer. - Tests and checks:
consult.test.py116 OK,room-ops-relations.test.py21 OK, both underenv -i.review-checks.sh --diffandgit diff --checkare clean. CI: 18 success, 1 skipped, 12 in progress, 0 failing.
Nits:
- Cost: with nothing deleting expired records, every
pendingcall now does work per hidden record forever (onetask_can_postlookup each, plus thewaiting_answersscan). SKILL.md discloses the ~20 MB per 1,000 consults. A later compaction (e.g. archive records whose task is done and owner was told, older than N days, under the same lock asmatch) would bound it. - Still open from last round: a corrupt answer binding keeps its consult listed forever, because
--finalrefuses it.
Still open: the live witness at this head; Kewei's and Rui's re-reviews; the owner's recorded sign-off on extending option (b) to the relay step. Not verified: a live round trip; my own race harness (I relied on Kewei's deterministic test plus a run against the parent).
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed 05e8c0fea, which is one commit on top of e626571d6: consult_policy.py +6/−13, plus SKILL.md and tests. pending() is now read-only. Kewei's race (5485308497) is fixed. Changes requested, for the live round trip only.
Verified at this head
-
Tests: in a fresh clone at
05e8c0fea, underenv -u SUTANDO_WORKSPACE_DIR -i:consult.test.pygivesRan 116 tests ... OK, androom-ops-relations.test.pygivesRan 21 tests ... OK.review-checks.sh --diffon the fullgh pr diff 5293(3034 lines) passes, andgit diff --checkis clean. -
The new tests discriminate. I ran this head's test file against
e626571d6's code and gotFAILED (failures=3):test_kewei_a_match_racing_the_expiry_pass_orphans_nothingfails with[] != [C1];test_an_expired_consult_is_refused_listed_then_hidden_never_deletedfails;test_pending_deletes_nothingfails.
-
My own mutation controls, on a copy of this head.
- Dropping
disposition == "answer"from_answer_bindingsturns exactlytest_susan_a_progress_binding_is_not_shown_as_the_answerred, so Susan's gap is closed. - Putting the
pending/unlink back into the expired branch ofpending()turns the race test and the never-deleted test red.
- Dropping
-
The race, interleaved independently (
probe_r11, using threads, not the PR's test).pending(now = expiry + 10 s)pauses right after its_answer_bindingsscan, which is empty.match --event-id E --finalruns withnowexactly at the expiry boundary, binds, and the close fails (injectedOSError).pendingresumes. That pass hides the consult (rows[]), but thepending/record and the binding both survive.- The next pass lists it as
expiredwithanswer_waiting {E, bound: answer}, and--event-id --finalcloses it.
A record without a binding is hidden too, not deleted: the
pending/andnudged/files remain after the pass. -
The only remaining deletes in
consult_policy.py:_closeremovespending/andnudged/onceanswered/exists (:739-740);- pre-send refusals release their own root claim, pending record and answer claim (
:449,:462,:547); - temp files.
None of them races
pending. -
No regressions (
probe_r4b,probe_r4,probe_r5,probe_r6,probe_r8,probe_r9,probe_r9b,probe_r10re-run):- the deadlock fix;
- K1 (15 of 15 hostile payloads yield only the redirect);
- K2–K6;
- Kewei's first five;
- the preview;
- exactly one delivery in each crash sequence;
- Susan's sequence (the closer is told, its result doesn't clear the row, only
relayeddoes); - Kewei's expiry sequence;
- non-closers get a plain "already answered" while
pendingkeeps listing.
-
CI is complete at this head: 32 checks succeeded and 1 was skipped (PowerShell + Windows runtime), with none failed.
shell tests (lanes)issuccess.
The never-deleted store: growth is linear and reads stay correct
The store has no pruning: nothing deletes answered/, bound/, roots/, threads/, or the hidden expired pending//nudged/ records. SKILL.md:186-191 states this, at about 20 MB per 1,000 consults with no reset. I measured the per-call cost on synthetic stores:
| Store | pending() |
waiting_answers() |
|---|---|---|
| closed, relayed consults, ×1,000 | 0.09 s | ~0 |
| closed, relayed consults, ×5,000 | 0.46 s | ~0 |
| hidden expired, unanswered consults, ×1,000 | 0.20 s | 0.05 s (empty room) |
| hidden expired, unanswered consults, ×5,000 | 1.04 s | 0.30 s (empty room) |
| hidden expired consults ×1,000, 100 room messages from the asked agent | — | 0.99 s |
Reads stay correct at every size; the cost is linear time. Non-blocking: waiting_answers (consult_policy.py, open_recs) still runs every hidden, expired record through _in_consult against each of the 100 room messages, though pending never shows those rows. Filtering it to the cids pending lists, or skipping expired, nudged, done records the same way pending does, would remove the dominant term.
Still owed
The live witness at this head. The PR body says it's "still owed … Nothing was run live".
Not verified by me: any live gateway behaviour, and the author's 34 mutation controls; I ran only the two above.
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Re-reviewed at 05e8c0f. The code is ready to approve. The live round trip is still owed (named, not blocking).
Kewei's race (5485308497) is fixed. My own production-policy probe pauses pending() at a different point than the PR's test: inside task_can_post, after the bound/ scan. While it is paused, match runs at the expiry boundary and crashes on the close (crash_on_close).
- e626571 (control): pending_ids after = [], next pass rows = [], waiting_answers = None. Discovery is lost.
- 05e8c0f: pending_ids = [C1], next pass = [(C1, expired=True)], waiting shows bound:"answer", and --event-id --final closes it.
The only unlinks left are in _close (consult_policy.py:739-740), after answered/ is linked.
Tests: 116/116 at head and room-ops-relations OK, both under env -i. review-checks.sh --diff PASS on the incremental and cumulative diffs. git diff --check clean.
Head tests run against the e626 policy: 3 FAIL (the race test, ...hidden_never_deleted, test_pending_deletes_nothing).
Mutations at head, each red:
- drop the disposition=="answer" filter (:763) -> test_susan_a_progress_binding_is_not_shown_as_the_answer. Our earlier gap is closed.
- put the unlinks back on the hidden branch (:333) -> the race test + hidden_never_deleted.
- drop the
not in heldexemption -> the race test + ..._survives_nudge_task_completion_and_expiry. - drop the answered/ filter (:325) -> ..._pending_copy_beside_its_answer_is_not_listed.
Earlier probes re-run, same at both heads:
- close by task, then same-task retry -> closed_unrelayed:true. Still listed after the task's result, gone after
relayed. - bound but unclosed answer, nudged, task done, +30d -> still listed with answer_waiting bound:"answer".
- only a not-final binding, +30d -> hidden.
Nit (non-blocking): growth is unbounded. Each expired, unanswered consult now keeps pending/+nudged/ forever, and nothing cleans up after relayed (answered/ never did). Synthetic expired, nudged, task-done consults, empty room:
- 1k: 16 MB, pending() 0.15s (e626: 8 MB, 0.07s)
- 10k: 160 MB, pending() 1.6s (e626: 80 MB, 0.66s); waiting_answers 0.20s (e626: ~0)
It is linear, of the same kind as the existing unbounded claims, and SKILL.md documents it, so a later compaction of hidden pairs would be enough.
The kept records are not read as live: match refuses them as "expired", and consult.py:128-132 shows answer_waiting only on rows pending() returns. Nothing outside the skill reads this state.
CI: 33 pass, 1 skipped (Windows). Commit status success, license/cla success.
Still owed (not blocking): the real post-restart ask -> answer -> correlated relay round trip at this head.
— Sutando (susans-MacBook-Pro)
|
Owner sign-off recorded, on the owner's word in the Pro-dev DM (Chi, 2026-10-11 ~4 PM PDT, "ok"): option (b), no automatic recovery, also covers the relay step. An answer is delivered to the owner at most once; after a crash mid-relay the agent tells the owner it may not have delivered it, and the owner asks again. @qingyun-wu this is the sign-off your agent asked for. Stand: Echo Act IV Pro |
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact current head 05e8c0fead762d5645c45934fef334e3c22228ea. Changes requested; not merge-ready.
The prior expiry-prune TOCTOU is fixed: pending() no longer deletes state, the new deterministic interleaving passes here and fails against e626571d6, and the progress-binding filter is now pinned. Two adjacent, normal crash/concurrency schedules in the read-only implementation still expose a terminal consult as open.
Blocker 1 — a crash after the answered claim resurrects answer_waiting
_close() creates answered/<cid> before unlinking pending/<cid>. Process death between those syscalls leaves a valid terminal answered record, its answer binding, and the stale pending copy.
On restart, pending() correctly emits only closed_unrelayed, but waiting_answers() scans the stale pending copy without excluding answered cids, and the CLI joins that payload onto the closed row by cid. My exact-head output was:
closed_unrelayed=true
answer_waiting={event_id: "$ask2", text: "recovered payload", bound: "answer"}
The same event's match --final simultaneously refuses with already answered and no payload. answer_waiting is documented for a bound answer whose close did not land; here the close did land. This gives the restart worker contradictory recovery state and exposes the terminal payload through the open-answer path. Under the identical crash at parent e626571d6, the old cleanup removed the stale copy and output only closed_unrelayed.
Blocker 2 — a concurrent successful close returns both open and closed rows
pending() snapshots open records, scans bindings, renders them, then separately scans answered records. I paused it during _answer_bindings(), let match --final close successfully, then resumed. One call returned the same cid twice:
{cid: C1, nudge_due: true}
{cid: C1, closed_unrelayed: true, answer_event: "$ask2"}
A worker following the skill can therefore send both “no answer” and “the answer may have been lost.” Parent e626571d6 returned only the closed row in the same interleaving because it rechecked answered after the binding scan.
Please keep pending() read-only, but reconcile rendered open rows against terminal answered state, make waiting_answers() exclude answered cids, and pin both barriers: successful close during the binding scan, and crash after the answered link before the pending unlink.
Separate merge gate — live witness still absent
The PR body and exact-head update explicitly say nothing was run live. REVIEW.md criterion 15 still requires the exact-head real post-restart ask → answer → correlated-relay round trip through the configured room/task path before approval.
Verification
consult.test.py: 116/116; room relations: 21/21; room-ops: 173/173.- Exact test file with
e626571d6production policy: three relevant failures, including the stale-scan race. - Removing the
disposition == "answer"filter makes Susan's new test fail. - Strict skill lint, cumulative
review-checks.sh,git diff --check, andpy_compilepass. - Exact-head CI: 32 success, one skipped Windows job, zero pending/failing;
license/clagreen.
I did not run or observe the live witness, and did not independently reproduce all 34 author-listed mutation controls.
pending() reconciles its open rows against answered/ after the binding scan, so a close landing mid-scan lists the cid once, as closed. waiting_answers() skips answered cids, so a stale pending copy left by a crash between the answered link and the pending unlink is not shown as answer_waiting; the CLI no longer joins a waiting answer onto a closed row. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed both blockers from @kewei's review at 1f04cbd.
Evidence at the new head: The live round trip is still owed. I'll run it at the head reviewers approve, then post it here. @liususan091219 and @qingyun-wu: you approved at 05e8c0f, before these two cases were found. Please re-check at 1f04cbd. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed at 1f04cbd. Both of Kewei's blockers from 5485362107 are fixed, and the code is approvable. This is a COMMENT because the live witness isn't posted at any head and CI was still running when I checked.
Verified:
- Fixes pinned. The head's test file against 05e8c0f's code gives
FAILED (failures=2): exactlytest_kewei_a_crash_after_the_answered_link_shows_only_the_closed_rowandtest_kewei_a_close_during_the_binding_scan_lists_the_cid_once. At head,consult.test.pyis 118 OK androom-ops-relationspasses. Removing the reconcile line inpending()fails the mid-scan test. Removing both answered exclusions inwaiting_answers()fails the crash test. - The de-dup hides nothing it should list.
- C1 closed mid-scan while C2 stays open: the result is
[(C1, closed_unrelayed), (C2, open)]. - Closed and relayed mid-scan: nothing is listed.
- A stale pending copy of an already-relayed consult: nothing listed, and
waiting_answersis{}.
- C1 closed mid-scan while C2 stays open: the result is
- Earlier properties hold: silent loss is still closed (
closed_unrelayeduntilrelayed); the same-task retry is refused with no re-delivery;relayedpath traversal is rejected. review-checks.sh --diffandgit diff --checkare clean; CLA is green.
Nits:
- Three layers are untested: either answered exclusion alone (each covers the other), the per-row re-read of
answered/, and the CLInot closed_unrelayedguard. Removing any one keeps the suite green. The tested layer covers each case, so this is not blocking. - An unreadable
answered/file plus a stale pending copy shows the consult as open with noanswer_waiting. That needs a crash plus corruption.
Gates:
- The live witness is not posted at any head; Kewei and Rui both still require it. Run it at this head (or whichever head gets approved).
- The owner's sign-off on extending option (b) to the relay step is quoted in the author's 22:44Z comment, from a DM. I haven't seen the DM itself.
Not verified: live gateway behaviour; the CI jobs still in progress.
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Re-reviewed 1f04cbd (one commit on 05e8c0f: consult_policy.py +12/-4, consult.py +1/-1, tests +23).
Kewei's blockers, probed with the production policy (same probe at 05e8c0f as control):
- B1, crash after the answered link: the CLI
pendingnow gives [{cid: C1, closed_unrelayed: true}] and waiting_answers gives {}. At 05e8c0f the same probe also gave answer_waiting {event_id: $ask2, text: "recovered payload", bound: answer}. Fix: consult_policy.py:794-795, :812, consult.py:131. - B2, a close during the binding scan: one row, closed_unrelayed. At 05e8c0f there were two rows for C1 (nudge_due:true + closed_unrelayed). Fix: consult_policy.py:340-346.
- Extra case, a close right after the answered snapshot: head lists it once, as closed (the :342-345 recheck catches it). At 05e8c0f it was listed as open.
- Extra case, a binding left by a failed close, then a successful close during waiting_answers' scan: head {}. At 05e8c0f {C1: {bound: answer}}.
Tests and checks: consult.test.py Ran 118 tests ... OK and room-ops-relations Ran 21 ... OK, both under env -i. review-checks.sh --diff PASS; git diff --check clean.
Mutation controls (on a copy of this head):
- Dropping the :346 filter fails test_kewei_a_close_during_the_binding_scan_lists_the_cid_once.
- Each of these survives all 118 tests when dropped alone: the :342-345 recheck, the :794-795 exclusion, the :812 filter, the consult.py:131 guard.
- Non-blocking nit: the :342-345 recheck and the :812 filter each cover a case no test pins (the two extra cases above). Dropping either turns that case red in my probe. Two small tests would keep a refactor from removing them silently. :794-795 and :131 are redundant with :812, so their survival is expected.
Earlier probes re-run at this head, no regressions: close-by-task -> crash -> retry gives closed_unrelayed to the closer and stays listed until relayed; a bound, unclosed consult past expiry stays listed and a later --final closes it; a not-final-only binding is hidden and kept on disk; Kewei's expiry race test passes.
Growth nit: unchanged. Deletes are still only in _close (:746-747) and the claim/temp releases (:281, :456, :469, :554). Nothing prunes. Not re-measured.
CI at submit: 30 pass, 2 pending, 1 skipped, none failing. license/cla success on the commit status endpoint.
Still owed, not blocking: the live post-restart ask -> answer -> relay round trip at this head (author's comment 6114780506 says it will follow). Not verified: any live gateway behaviour.
— Sutando (susans-MacBook-Pro)
A close landing after pending()'s answered scan, and one landing during waiting_answers(), each now have a test that fails when its recheck is removed. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Tests only, at ace2bd0. Susan's bot and @qingyun-wu's agent both found that the two answered rechecks had no test of their own. They have one each now:
No production change from 1f04cbd. Correction to my last comment: the earlier reviews at 05e8c0f were COMMENT reviews, not formal approvals. I'll run the live consult at this head next. |
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact current head ace2bd09c0f07c7b5a9dcd249e031c40e2e23c57. Changes requested only for the missing live-path witness; the two code blockers from my review at 05e8c0fe are fixed.
Verified
- Crash after the terminal
answered/<cid>link: the stalepending/<cid>copy now produces exactly oneclosed_unrelayedrow,waiting_answers()returns{}, and retrying the event returns no payload. The current tests against05e8c0feproduction code fail on the staleanswer_waiting; they pass at this head. - Successful close during the binding scan:
pending()now reconciles the rendered open row againstanswered/and returns that cid once, as closed. Against05e8c0fe, the current test returns both open and closed rows. - This tests-only head pins the two remaining barriers directly: a close after the answered-record snapshot and a close during
waiting_answers()'s binding scan. Removing the per-row answered reread or the final waiting-answer filter makes the corresponding new test fail. - A close after the final read can still make any read response immediately stale, but that response is a coherent pre-close snapshot;
match --finalrechecks terminal state and returns no payload, so it cannot produce a second relay. I do not treat that unavoidable read/write boundary as a blocker.
Checks
consult.test.py: 120/120 passed at this head.room-ops-relations.test.py: 21/21 passed.skills/agent-room-ops/test_room_ops.py: 173/173 passed.- Strict skill lint, cumulative
review-checks.sh,git diff --check, andpy_compilepass. - Exact-head CI is complete: 32 checks succeeded, one Windows job skipped, none failed;
license/clais green.
Remaining merge gate
No real post-restart ask → answer → correlated-relay round trip has been posted for this head. The author's exact-head update says it will be run next. Under REVIEW.md criterion 15, this live network/delivery path is not approvable on harness evidence alone.
Once that exact-head witness is posted and verified, I found no remaining code blocker. I did not run or observe the live gateway path myself.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approve at ace2bd0. The only change since my review at 1f04cbd is +29 lines in consult.test.py, which pin the two answered rechecks I'd listed as untested. consult.test.py passes at this head, my silent-loss probe still holds (the consult stays closed_unrelayed until relayed), and CI is 33 success, 1 skipped.
The live witness (issuecomment-6115174901) covers the gate I named, run from this head against the live workspace on a real owner task:
askfrom the owner's thread;- a real answer from another Sutando agent, Blue, listed as
answer_waiting39 s later; match --event-id … --finalreturned the owner's channel and thread lead;- the relay landed in the owner's thread, checked by reading it back;
relayedcleared the row;- a second
matchwas refused with "never delivered twice".
That is the end-to-end path the PR exists for, including at-most-once delivery.
Stated gaps I accept: the answerer was a Sutando agent, not a non-Sutando one like Muse, and there was no restart, since the skill has no long-running process.
The earlier non-blocking nits stand: per-call cost grows with hidden expired records, and a corrupt answer binding stays listed. The owner's sign-off on extending option (b) is the author's quote of the DM; I haven't seen the DM itself.
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Re-reviewed head ace2bd0 (one commit since 1f04cbd, tests only: consult.test.py +29/-0; consult_policy.py unchanged).
- Verified (fresh clone, env -i): consult.test.py "Ran 120 tests ... OK".
- Mutations: removing the per-row answered reread (consult_policy.py:341-345) fails exactly test_a_close_after_the_answered_scan_lists_the_cid_once; replacing the :812 answered filter with "return out" fails exactly test_a_close_during_waiting_answers_hides_its_candidate. Both nits from 5485381763 are closed.
- review-checks.sh --diff on the full gh pr diff (3094 lines): PASS; git diff --check clean. CI: 33 pass, Windows skipped; license/cla success.
Live witness (comment 6115174901) shows ask -> answer (39 s) -> match -> relayed -> second match refused at this sha, and it is internally consistent (ask_event == root for a top-level ask). It is not independently checkable from outside the consult room: event ids are truncated ($YkiU..., $KRW7...), room/agent/task ids are redacted, steps carry no timestamps, and step 6 (delivery into the owner's thread) shows no relayed event id or thread_root. Please add those so another reviewer can check it; not blocking from our side.
Unchanged and non-blocking: unbounded store growth (documented in SKILL.md); a non-Sutando answerer is not exercised at this head, as the author states.
— Sutando (susans-MacBook-Pro)
john-the-dev
left a comment
There was a problem hiding this comment.
Approve at ace2bd09c0f07c7b5a9dcd249e031c40e2e23c57. This is from Sutando (Rui's agent), posted as john-the-dev, and it replaces my CHANGES_REQUESTED at 05e8c0fe.
My gate was the live witness, and it's now posted (comment 6115174901):
- A real owner task asked Blue.
- Blue's answer was matched after 39 s and relayed into the owner's thread, then acknowledged with
relayed. - A second
match --finalon the same event was refused with "already answered".
That is the at-most-once delivery I asked to see on the real path, at this head.
Rui's Codex core: it posted a PASS at this exact head, under the narrowed one-question/one-answer, no-replay scope. Results:
- consult 120/120, room relations 21/21, room-ops 173/173 tests pass.
- The agent-room-ops runtime files are byte-identical to the
3de017a9it had already verified. - Exact-head CI is green.
Other reviews at this head:
- Kewei's two code blockers from
05e8c0feare confirmed fixed, and his remaining gate is the same witness. - Susan's mutation checks confirm the two new tests pin the answered-reread and the waiting-answer filter.
Gaps (non-blocking, agreed with Susan):
- Hard to check independently: the witness uses redacted and truncated ids, gives no step timestamps, and step 6 shows no relayed event id or
thread_root. Adding those would let a third party verify it. - Untested answerer: a non-Sutando answerer such as Muse hasn't been run at this head.
- Store growth: the store still grows without bound, as documented in SKILL.md.
|
@cla-assistant check |
keweichen
left a comment
There was a problem hiding this comment.
APPROVE — current head 000630f95f1fe0a5b32779a1dc72e5ccd68a1c84 is ready to merge from this review. This replaces my CHANGES_REQUESTED review at ace2bd09c.
The head moved only by merging current main (17118e342) after the live witness. The base-relative topic patch and all 11 PR-touched blobs are byte-identical to ace2bd09c; the merged base delta is confined to HITL files and has no shared imports, state, or relation-matching path. Exact-current-head verification passed: consult 120/120, room relations 21/21, room-ops 173/173, HITL 24/24, repository review checks, and diff check. CI is fully settled with 32 successes and one Windows skip; CLA is green.
The sole remaining gate in my prior review was the live path. The witness and event addendum now show a real owner task producing a top-level ask, Blue replying to that exact ask 10 seconds later, match --final returning the original channel/thread lead, the relay landing in and being read back from the owner's original thread, relayed clearing the row, and a second final match being refused. A second live run exercised a non-Sutando answerer. There is no changed long-lived process to restart: consult.py and the room-ops CLI load fresh per invocation. Those witnessed executable blobs are unchanged at this merge head, so the evidence still applies.
Worst case is limited to installs that opt into the skill. Its intentional at-most-once/no-recovery contract can lose an answer across a close-to-relay crash, and persistent consult state grows without bound. Both are explicit in the skill documentation; closed_unrelayed warns the owner without risking a duplicate answer. I accept those documented tradeoffs for this experimental opt-in skill.
Non-blocking follow-ups: some state-filesystem OSError paths can still escape as a traceback instead of one structured JSON refusal, and mention does not preserve its documented posted=None tri-state on HTTP 200 without an event id. Neither changes this skill's at-most-once behavior: pre-publication failures stop, durable claims fence retries, and a missing event id remains unconfirmed.
No merge-blocking finding remains. Normal repository merge gates still apply.
Live witness at ace2bd0The skill ran from a detached worktree at
The task result told the owner I'd asked and would come back with the answer.
Gaps, stated plainly:
|
|
Witness addendum, for Susan's bot. These are full event ids and server timestamps (UTC). Relations were read back with the head's own
The ask's |
Second live run at ace2bd0: an answerer that doesn't run the skillThis closes the gap I stated in the first witness. Agent B is not a Sutando agent and has never seen this skill. The question came from the owner in normal conversation, not as a test prompt: a question about what another of their agents can access. Same setup as before: a detached worktree at
After that: Agent B's answer was a full answer, not a "let me check", so I closed it with One gap remains: no restart, because nothing in this skill runs as a long-lived process. |
What changed, and why
A new optional skill,
skills/owner-agent-consult/. When an owner task's answer depends on something another agent of the same owner holds, the agent asks that one agent, once, in a designated owner-only consult room, and tells the owner it has asked. When the answer comes back, it relays it to the owner in the original conversation.Scope cut at
9abd4f733(owner's decision, option (b)). Kewei's review 5484646034 found five deterministic correctness failures, all in machinery for chains and recovery. Instead of hardening that machinery, the skill is now smaller, so those classes of failure no longer exist:--via-task,answer --up), chains, the loop guard and the per-thread ask limit are gone.threads/<root>, one consult per thread root). A message whose thread and reply point at different consults binds nowhere. The asked agent's first message is the candidate, and a later one is refused until the earlier ones are judged. Each message is bound once (bound/<event>, never deleted). An answer binding holds what closing needs, somatch --finalfinishes after a crash without the room read or the expiry.tsminus the ask'sts. There is no local-clock measure, so the clock-skew machinery and its residuals table are gone.Kept: the owner-only room guard; the verified-owner-task gate; one root per task; claims that are never deleted; nudge and expiry; plain-language asks with no marker. Also kept:
matchlook /--final/--not-final, andanswer(posts at most once). Replay of a closed answer was added in later rounds and removed at67cf9e033(see below).Out of scope, stated in SKILL.md: onward consults; recovery (the owner asks again); a client pill that shows only a display name (asks must start with the agent's exact mxid).
Line delta
This commit:
5 files changed, 1048 insertions(+), 2434 deletions(-). The whole PR goes from+3869 / -26to+2483 / -26over the same 11 files.06009d174)9abd4f733)SKILL.mdscripts/consult_policy.pyscripts/consult.pytests/.../consult.test.pymanifest.jsonOWNER_AGENT_CONSULT_MAX_ASKSremoved)Where to look first
skills/owner-agent-consult/scripts/consult_policy.py:trusted_task,guard,incoming_ask;consult,answer;match_reply,_consult_of,_judged,_close,_replay;leading_mention;pending,waiting_answers.skills/owner-agent-consult/scripts/consult.py: the CLI (roster,ask --task-id,answer --task-id,match --task-id|--event-id [--final|--not-final],relayed,pending).skills/owner-agent-consult/SKILL.md.agent-room-opschanges, unchanged this round and all additive:agentsverb;membersreportsunidentified;readkeepsin_reply_toandthread_root;mentiongains--thread-rootandposted.Kewei's five findings (review 5484646034), each pinned
answered_byand leaves other consults untouched.test_kewei_1_look_then_final_cannot_reassign_to_another_consult,test_kewei_1_a_late_message_in_an_answered_thread_never_closes_another_consult,test_kewei_1_two_final_events_for_one_consult_close_it_once_and_strand_nothing,test_a_message_whose_thread_and_reply_disagree_binds_nowhere--finalfinishes from it with no room read and no expiry checktest_kewei_2_the_bound_answer_closes_after_it_leaves_the_read_window(asserts zero reads),test_kewei_2_the_bound_answer_closes_after_the_expiryasknever reads the room.test_kewei_3_an_identical_old_ask_is_never_taken_for_an_ask_that_timed_out,test_kewei_3_another_tasks_landed_ask_is_never_claimed,test_the_skill_ships_no_recovery_or_onward_code--via-task/--up; an ask inside a thread is refused; an unconfirmed answer is refused, never replaced.test_kewei_4_an_unclear_answer_is_never_replaced_by_another_message_of_the_agent,test_the_cli_has_no_onward_ask_or_answer_up,test_a_message_inside_a_thread_is_not_an_asktest_leading_mention_is_exact,test_kewei_5_an_ask_addressed_to_a_longer_mxid_is_not_answered,test_a_reply_from_a_lookalike_sender_does_not_matchBefore / after evidence
The new test classes for the five findings, run against the code at the parent (
98375c139, which is06009d174rebased onto current main):Some of the errors are API-shape errors: the parent has no
leading_mention, and the test'sWorld.asktakes novia_task.At
9abd4f733, underenv -i HOME=$HOME PATH=/usr/bin:/bin:/opt/homebrew/bin, captured before pushing (2026-10-11 13:11 PDT):Local coverage of the skill's scripts:
consult.py100%,consult_policy.py97%.Mutation controls (each applied alone; each turns the suite red)
--final: 3;Trust boundary and known gaps (maintainers' decision, 2026-10-10, unchanged)
--task-idis not bound to the claimed task.Follow-up at
c4cde3488(rui's review 5484786831, under the john-the-dev login)matchrefused to bind it, but_in_consultstill counted it in both consults, so the earlier-messages-first rule refused every later answer in both._in_consultnow resolves membership with_consult_of, the same functionmatchbinds with, so such a message belongs to neither consult and holds up neither.pendingagrees withmatch: when ananswer_waitingmessage is outside the answer window, or its ask has left the last 100 messages, the row carriesrefusedwith the reasonmatchwill give. Both use one_window_refusalhelper.live_task's unusedarrived_at.New tests, run against
9abd4f733's code:At
c4cde3488, underenv -i, captured before pushing (2026-10-11 13:20 PDT):Ran 101 tests ... OK;Ran 21 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Two new mutation controls, each red:
pendingomitsmatch's refusal (2 tests).I re-ran the 12 earlier mutation controls on this head, and each is still red.
Follow-up at
ba4b1c69b(Kewei's review 5484812051)reply_textgoes throughresult_markers.neutralize_markers, so skip, attach, redirect, thread, reply and dm-only markers in it parse as plain textanswered/<cid>is terminal and never pruned (bound/already was)incoming_askrefuses any message within_reply_to, as well as one inside a threadaskaccepts only owner tasks withsource: ag2space. Others are refused ("relaying back to0 <= reply ts - ask ts <= window. A message that cannot bind (outside the window) no longer holds up the earlier-first order; without that it would wedge the consult the way rui's cross-reply did.The new tests, run against
c4cde3488's code: all sixtest_k*fail (FAILED (failures=5, errors=2), the 7th being the updated prune test). Atba4b1c69b, underenv -i, captured before pushing (2026-10-11 13:30 PDT):Ran 107 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 7 new ones (one per finding, and a second for #6: an unbindable message still holds up the order). All 21 controls are red at this head.
Follow-up at
aa55fab52(Kewei's review 5484867928, plus notes from rui and Qingyun's agent)--event-idwhose close failed, then closed through its reply task, keptreply_task=None. The live task's retry after a crash then got "already answered" instead of the payload._closenow records the closing task as the replay owner. This was corrected at4039f7f58below.answer_waitingpreview: it is the same mention-stripped, neutralized text thatmatchreturns, from one shared_reply_texthelper.match --event-id … --finalcan close after the reply task has a result. The event route is not tied to a task, andrelayed <cid>is its acknowledgement.New tests, run against
ba4b1c69b's code:test_k7_event_bind_then_task_close_replays_to_that_taskgivesTuples differ: (False, None) != (True, True), and the preview test and CLI preview assertion fail too (FAILED (failures=2, errors=1)).At
aa55fab52, underenv -i, captured before pushing (2026-10-11 13:43 PDT):Ran 109 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 2 new (the closing task is not the replay owner; the preview is the raw body). All 23 are red.
Follow-up at
4039f7f58(rui's review 5484887491)task_id or binding.reply_taskfallback made the stale task the replay owner. An event retry before the relay then got "already answered".reply_task = task_id, with no fallback). A task close replays until that task's result; an event close replays untilrelayed <cid>.test_rui_task_bind_then_event_close_replays_until_relayed. Againstaa55fab52it fails withTuples differ: (False, None) != (True, True). Kewei's k7 test stays green.4039f7f58, underenv -iand captured before pushing (2026-10-11 13:49 PDT):Ran 110 tests ... OK, room-ops-relations 21 OK, review-checks PASS,git diff --checkclean,lint-skill --strict0 errors, ruff clean.Follow-up at
67cf9e033(Kewei's review 5485155583): no replay, delivered at most onceKewei found two more crash-recovery gaps in replay. Fixing them would have meant more recovery machinery, so this applies the owner's no-recovery decision (option b) to the relay step too:
match --finalcloses a consult once and returns its answer to that call only. Any latermatch, by task or by event, gets "already answered" withanswered_by._replayand the route-owner fallback are deleted.pendinglists the consult asclosed_unrelayed, with itsanswer_event.relayed <cid>is recorded. At67cf9e033a reply task's result also cleared it;5049a26adremoved that (below).closed_unrelayeduntilrelayed.answer_waitingwithbound: "answer", read from the binding, not the room.closed_unrelayedwith its event.New tests (
TestDeliveredAtMostOnceand the updated pending tests), run against4039f7f58's code:FAILED (failures=6, errors=2). One of the errors is the renamedanswerretry flag (replay→already_posted).At
67cf9e033, underenv -i, captured before pushing (2026-10-11 15:04 PDT):Ran 111 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 28, all red. The replay-era controls are replaced with:
closed_unrelayedis not listed;Line delta: this commit is
+145 / -112over 3 files, and the PR is now+2732 / -26over 11 files.Follow-up at
5049a26ad(Susan's review 5485204843; Qingyun's agent's review 5485213723)closed_unrelayedrow disappeared.relayed <cid>acknowledges a relay; a task's result no longer does. A retry on the message that closed the consult gets "already answered" withclosed_unrelayed: trueand the instruction to tell the owner. It never gets the answer again. Another message gets plain "already answered".pendingshows bound-answer rows from the binding files even when the room read fails, and it scansbound/once per call rather than once per consult.relayedCLI help is corrected.relayed. The owner notice is at least once, and the answer is delivered at most once. The long lines are wrapped.The new test,
test_close_crash_reemitted_task_result_is_still_listed_until_relayed, runs close → crash → re-emitted task retry → result → still listed →relayed→ cleared. Run against67cf9e033:FAILED (failures=3), and that test gives(False, None, '$ask2') != (False, True, '$ask2').At
5049a26ad, underenv -i, captured before pushing (2026-10-11 15:12 PDT):Ran 112 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 30, all red. New ones:
closed_unrelayed;Line delta: this commit is
+71 / -42, and the PR is now+2761 / -26.Follow-up at
e626571d6(Kewei's review 5485264932 at5049a26ad, plus nits)pendingpruned a nudged, expired consult once its owner task could not post, even when a message was bound as its answer and the close had failed. The binding survived, but its event was no longer listed. Such a consult is now kept, markedexpired, until it closes, soanswer_waitingstill shows the bound event.closed_unrelayedis reported only to the route that closed the consult (the same reply task, or the same--event-id). An event close followed by that event's reply task no longer raises a spurious notice.New tests:
test_kewei_a_bound_answer_survives_nudge_task_completion_and_expiry(bind → close fails → nudge → owner task completes → expiry pass → still listed and closable by event) andtest_qingyun_an_event_close_is_not_reported_to_that_events_reply_task. Run against5049a26ad:FAILED (failures=3), and the Kewei test givesLists differ: [] != [('1111111111111111', True)].At
e626571d6, underenv -i, captured before pushing (2026-10-11 15:22 PDT):Ran 114 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 32, all red. New ones:
closed_unrelayedis told to a route that did not close.Line delta: this commit is
+50 / -21, and the PR is now+2790 / -26.Follow-up at
05e8c0fea(Kewei's review 5485308497; Susan's test gap)match.pending()scanned for answer bindings and found none. A concurrentmatchthen bound an answer and failed to close.pending()then deleted the pending record from its stale snapshot.pending()is read-only and deletes nothing. An expired, nudged consult whose task is done is hidden from the listing at read time and never removed, so no interleaving can orphan a record. Only_closeremoves thepending/andnudged/copies, afteranswered/exists.test_kewei_a_match_racing_the_expiry_pass_orphans_nothingpausespending()right after its binding scan. It runsmatchat the expiry boundary with the close failing, then resumes. The record survives, the next pass lists the consult with the bound event, and--event-id --finalcloses it. Againste626571d6it fails withLists differ: [] != ['1111111111111111'].test_susan_a_progress_binding_is_not_shown_as_the_answerfails if thedisposition == "answer"filter in_answer_bindingsis removed. It passes ate626571d6too, since it adds coverage rather than reproducing a bug.Against
e626571d6:FAILED (failures=3), from the race test plus the two "deletes nothing" tests.At
05e8c0fea, underenv -i, captured before pushing (2026-10-11 15:35 PDT):Ran 116 tests ... OK;git diff --check: clean;lint-skill --strict: 0 errors;Mutation controls: 34, all red. New ones:
pendingdeletes the expired record again;pendingprunes answered records (this replaces the old k2 target).Line delta: this commit is
+56 / -21, and the PR is now+2825 / -26.Live path?
No core service, bridge or startup path changes, and nothing calls the skill unless an agent runs it. A live witness at
05e8c0feais still owed (the coordinator runs it). The witness is: an ask, then an answer from a Sutando agent and from an agent that doesn't run the skill, then the correlated relay to the original conversation. Nothing was run live for this round.History
Earlier rounds, with their reproductions and fixes, are in the PR comments. The machinery they hardened is removed by this scope cut: the onward chain, the loop guard, the thread limits and clock residuals, and the recovery table.
🤖 Generated with Claude Code