Skip to content

feat(ag2-sparrow): queue AG2 Space inbox tasks into one persistent Claude Code cloud session - #5660

Merged
sonichi merged 47 commits into
mainfrom
feat/sparrow-claude-cloud-session-adapter
Oct 17, 2026
Merged

sonichi merged 47 commits into
mainfrom
feat/sparrow-claude-cloud-session-adapter

Conversation

@sonichi

@sonichi sonichi commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Queue AG2 Space inbox tasks into one persistent Claude Code cloud session

A Sparrow adapter that feeds a long-lived Claude Code cloud session one task per turn, with a result lifecycle owned by one module and the work dir protected by the package's one-process lock.

Built on the merged prerequisite #5669 (holder_lock, fd62294). Two commits on top of main:

  1. Tests only, gate off — the cloud-session task-lifecycle contract as executable tests (TASK_LIFECYCLE_CONTRACT=1 turns them on; against the stub API every gated test fails and the run ends in seconds), the interlock contract for all three adapters as thin owners of holder_lock, the routine/Codex behavioural differential against the merge-base goldens, the write spy per adapter boundary, the production-bridge witness reusing the bridge's own broker fixture.
  2. Implementation, gate on — src/task_lifecycle.py (vendored) as the one result-lifecycle owner; the three adapters acquire the lock first (task_io.acquire_lock is a thin owner over holder_lock, no pidfile lock, no pre-flock peek), publish, then create their directories through owner.root_fd; SIGTERM only sets a flag and the loop releases within one step (the backoff wait wakes on the flag); the attributed stale connector.pid rule; ag2-sparrow-hatch install given stop → install → start.

Design v11 (self-contained, every clause naming its test) is posted as a comment on this PR. Stated limits, in the note: after startup the shared loop serves on the held inode's path resolved once (a rename of the root while serving is not followed); the Hatch installer's order covers the unit only (pip replaces the code before it runs); ps lstart has one-second resolution; the prior-release writer in the test is a verbatim copy of main's acquire_lock.

Merge order per the owner: prerequisite (merged) → this PR → #5665 (keepawake self-upgrade re-ordered) → #5629 (parked).

🤖 Generated with Claude Code

…aude Code cloud session

A new opt-in adapter, ag2_sparrow/adapters/claude_cloud_session, turns each
tasks/task-*.txt into one `claude -p <text> --cloud <session_id> --output-format json`
turn in a session the owner created once and connected to AG2 Space. The session
posts the reply itself, so the result is [no-send] plus the session URL.

It reuses the routine adapter's payload builder, rate limiter, result lines and
serve loop (build_payload gains text_max, result_body gains label), keeps owner-only
by default, writes a pre-send marker so a crash mid-send is never resent, and paces
turns with --min-gap-seconds and --max-per-hour (state/sends.json).

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Live witness at 42dcabc (REVIEW.md §15)

One real task through the shipped adapter into a prepared cloud session, with the agent posting in a test room. Times are UTC from the session's own event log; ids are left out.

Preparation (once).

  1. claude --cloud "<agent name>" from a terminal (interactive; trust prompt; it printed the session_… id). The folder was not a git repo, so the session started empty.
  2. In that session's web page, "+" → Connectors → the AG2 Space connector switched on. (Before the switch, the session reported no mcp__ag2_space__* tools; after it, a CLI-queued turn listed ag2_whoami, room_action_execute and the rest.)
  3. The standing prompt from session-prompt.md queued with claude -p "<prompt>" --cloud <session_id>. Session log: 07:40:16 assistant: ready.

The task. A Sparrow-shaped owner-tier task file in a scratch work dir (channel_id = the test room, no thread), then:

python3 -m ag2_sparrow.adapters.claude_cloud_session.session_adapter \
  --work <scratch dir> --session-id <session_id> --claude <path to claude> --once 1
00:40:40 queue -> task-wit5660-… owner chars 441
00:40:41 result <- task-wit5660-… ok  <session_id>
rc=0 after 1s
results/task-wit5660-….txt: [no-send]
# claude cloud session: https://claude.ai/code/<session_id>?from=cli&m=0

In the session (its event log):

07:40:41 user: [sparrow task] … room=<test room> thread=none tier=owner collaborator=false task_id=task-wit5660-… <UNTRUSTED_TASK_f6701d77> Witness for the cloud-session adapter: reply with the word PONG, then how many messages you have answered in this session so far. </UNTRUSTED_TASK_f6701d77>
07:40:46 tool_use mcp__ag2_space__room_action_execute: room.message.send body="PONG — this is the 1st AG2 Space message I've answered in this session." room_id=<test room>
07:40:51 tool_result: status completed, one event id
07:40:54 assistant: … posted task-wit5660-…

In the room: one message from the agent with that text. Queue to post: 11 s. The session kept its memory across turns (it distinguished this task from the earlier probe message in the same session).

Not exercised live: failure paths (non-zero exit, timeout, hourly cap), which the unit tests cover with a fake claude.

@sonichi
sonichi requested a review from keweichen October 17, 2026 07:41
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_cloud_session/session_adapter.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/routine_adapter.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/task_fields.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/relay_loop.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/task_io.py (98.8%): Missing lines 162-163
  • packages/ag2-sparrow/ag2_sparrow/task_lifecycle.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/task_priority.py (64.7%): Missing lines 27-29,38,44-49,52-55,88-89,99-100
  • packages/ag2-sparrow/tests/adapter_differential_driver.py (34.0%): Missing lines 48-55,71,96-100,118-119,121-124,126-129,132-140,142-146,149-150,154-156,165,167-178,180-182,184-185,187-200,202,204-207,211-219,221-222,226,228-239,241-242,245-249,251,255-259,262-263,265-267,269-270
  • packages/ag2-sparrow/tests/test_adapter_boundaries.py (99.1%): Missing lines 24
  • packages/ag2-sparrow/tests/test_adapter_differential.py (100%)
  • packages/ag2-sparrow/tests/test_adapter_relay_loop.py (97.7%): Missing lines 158-161,163
  • packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py (99.8%): Missing lines 99
  • packages/ag2-sparrow/tests/test_claude_routine_adapter.py (100%)
  • packages/ag2-sparrow/tests/test_cloud_session_contract.py (95.5%): Missing lines 117-123,125,271,318-320
  • packages/ag2-sparrow/tests/test_cloud_session_interlock.py (96.2%): Missing lines 40-41,174,184,217-219,238,240,248-249,417-419,448
  • packages/ag2-sparrow/tests/test_codex_connector.py (100%)
  • packages/ag2-sparrow/tests/test_hatch_adapter.py (100%)
  • packages/ag2-sparrow/tests/test_task_lifecycle.py (98.7%): Missing lines 58,80-81,83-84,100-101,104,162,284,307,431,742,1178-1182,1460,1834,1897,1941
  • src/task_lifecycle.py (93.7%): Missing lines 157,159,180-194,216,241,248,251-260,262-267,546-547,583
  • src/task_priority.py (100%)
  • src/undelivered_quarantine.py (100%)

Summary

  • Total: 5621 lines
  • Missing: 239 lines
  • Coverage: 95%

packages/ag2-sparrow/ag2_sparrow/task_io.py

Lines 158-167

  158     except OSError:
  159         pass
  160     try:
  161         if sys.platform == "darwin":
! 162             import fcntl
! 163             path = fcntl.fcntl(fd, fcntl.F_GETPATH, bytes(1024)).split(b"\0", 1)[0].decode()
  164         else:
  165             path = os.readlink("/proc/self/fd/%d" % fd)
  166         st, held = os.stat(path), os.fstat(fd)
  167     except OSError as e:

packages/ag2-sparrow/ag2_sparrow/task_priority.py

Lines 23-33

  23 # Relative inside the ag2-sparrow package copy; a flat load (importlib in tests, tools,
  24 # bridges) has no parent package, so src/ goes on sys.path instead.
  25 try:
  26     from . import local_task_protocol as _ltp
! 27 except ImportError:
! 28     sys.path.insert(0, str(Path(__file__).resolve().parent))
! 29     import local_task_protocol as _ltp  # noqa: E402
  30 
  31 _ORDER = {"urgent": 0, "normal": 1, "low": 2}
  32 _VALID = frozenset(_ORDER.keys())
  33 _DEFAULT = "normal"

Lines 34-42

  34 
  35 
  36 def is_valid_priority(value: str) -> bool:
  37     """True iff `value` is a recognized priority enum string."""
! 38     return value in _VALID
  39 
  40 
  41 def default_priority_for_source(source: str, access_tier: str | None = None) -> str:
  42     """Recommended priority for a given source. Writers should pass the

Lines 40-59

  40 
  41 def default_priority_for_source(source: str, access_tier: str | None = None) -> str:
  42     """Recommended priority for a given source. Writers should pass the
  43     `access_tier` (when known) so non-owner channel tasks demote correctly."""
! 44     s = (source or "").lower().strip()
! 45     if s in ("voice", "phone"):
! 46         return "urgent"
! 47     if s in ("chat", "context-drop"):
! 48         return "normal"
! 49     if s in ("discord", "telegram", "slack"):
  50         # Owner-tier traffic stays normal; team/guest is demoted so a channel ping
  51         # from a non-owner never preempts owner work.
! 52         return "normal" if (access_tier or "owner").lower() == "owner" else "low"
! 53     if s in ("health-check", "sync-memory", "sync-workspace", "cron"):
! 54         return "low"
! 55     return _DEFAULT
  56 
  57 
  58 def parse_priority_from_text(content: str) -> str:
  59     """Read the `priority:` header from a task-file body. Returns the

Lines 84-93

  84 def parse_priority_from_file(path: Path) -> str:
  85     """Read priority from a task file. Missing file -> "normal" (fail-open)."""
  86     try:
  87         return parse_priority_from_text(path.read_text(errors="replace"))
! 88     except (OSError, UnicodeDecodeError):
! 89         return _DEFAULT
  90 
  91 
  92 def sort_tasks_by_priority(paths: Iterable[Path]) -> List[Path]:
  93     """Sort an iterable of task file paths so the highest-priority comes first.

Lines 95-104

   95     enriched: List[Tuple[int, float, Path]] = []
   96     for p in paths:
   97         try:
   98             mtime = p.stat().st_mtime
!  99         except OSError:
! 100             mtime = 0.0
  101         prio = parse_priority_from_file(p)
  102         enriched.append((_ORDER.get(prio, _ORDER[_DEFAULT]), mtime, p))
  103     enriched.sort(key=lambda t: (t[0], t[1]))
  104     return [p for _, _, p in enriched]

packages/ag2-sparrow/tests/adapter_differential_driver.py

Lines 44-59

  44 """
  45 
  46 
  47 def write_task(work, tid, tier, channel, body, mtime):
! 48     lines = ["id: " + tid, "timestamp: 2026-10-16T00:00:00Z", "source: ag2space"]
! 49     if channel:
! 50         lines.append("channel_id: " + channel)
! 51     lines += ["access_tier: " + tier, "priority: normal", "task: " + body]
! 52     p = work / "tasks" / (tid + ".txt")
! 53     p.parent.mkdir(parents=True, exist_ok=True)
! 54     p.write_text("\n".join(lines) + "\n", encoding="utf-8")
! 55     os.utime(p, (mtime, mtime))
  56 
  57 
  58 def norm_text(base, text):
  59     text = text.replace(os.path.realpath(base), "<base>").replace(str(base), "<base>")

Lines 67-75

  67         return {k: ("<n>" if k in TIMING_KEYS else norm(base, v)) for k, v in value.items()}
  68     if isinstance(value, list):
  69         return [norm(base, v) for v in value]
  70     if isinstance(value, float) and value > 1e9:
! 71         return "<t>"
  72     if isinstance(value, str):
  73         return norm_text(base, value)
  74     return value

Lines 92-104

   92 
   93 
   94 def drop_lock(work):
   95     """Remove the lock dir a holder_lock revision keeps outside the work root (older ones keep none)."""
!  96     try:
!  97         from ag2_sparrow import holder_lock
!  98     except ImportError:
!  99         return
! 100     shutil.rmtree(holder_lock.lock_dir(str(work)), ignore_errors=True)
  101 
  102 
  103 def summary(base, work, calls, adapter="codex"):
  104     results = {}

Lines 114-160

  114 class Broker:
  115     """The routine's fire endpoint: replies from a script, the last entry repeating."""
  116 
  117     def __init__(self, script):
! 118         broker = self
! 119         self.script, self.calls, self.n = script, [], 0
  120 
! 121         class Fire(http.server.BaseHTTPRequestHandler):
! 122             def do_POST(self):
! 123                 body = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode("utf-8")
! 124                 broker.calls.append({"path": self.path, "anthropic-version": self.headers.get("anthropic-version"),
  125                                      "content-type": self.headers.get("Content-Type"), "body": json.loads(body)})
! 126                 status, headers, delay = broker.script[min(broker.n, len(broker.script) - 1)]
! 127                 broker.n += 1
! 128                 time.sleep(delay)
! 129                 raw = json.dumps({"claude_code_session_id": "session_%d" % broker.n,
  130                                   "claude_code_session_url": "https://claude.ai/code/session_%d" % broker.n}
  131                                  if status == 200 else {"type": "error", "error": {"type": "x"}}).encode()
! 132                 try:
! 133                     self.send_response(status)
! 134                     for k, v in headers.items():
! 135                         self.send_header(k, v)
! 136                     self.send_header("Content-Length", str(len(raw)))
! 137                     self.end_headers()
! 138                     self.wfile.write(raw)
! 139                 except OSError:
! 140                     pass
  141 
! 142             def log_message(self, *a):
! 143                 pass
! 144         self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fire)
! 145         threading.Thread(target=self.server.serve_forever, daemon=True).start()
! 146         self.base = "http://127.0.0.1:%d" % self.server.server_address[1]
  147 
  148     def close(self):
! 149         self.server.shutdown()
! 150         self.server.server_close()
  151 
  152 
  153 def routine(work, broker, once, extra=()):
! 154     from ag2_sparrow.adapters.claude_routine import routine_adapter as ra
! 155     with contextlib.redirect_stderr(io.StringIO()):
! 156         return ra.main(["--work", str(work), "--trigger-id", "trig_01DIFF", "--allow-insecure-api-base",
  157                         "--api-base", broker.base, "--once", str(once), "--poll", "0.01", *extra],
  158                        {"CLAUDE_ROUTINE_TOKEN": "t"})
  159 

Lines 161-274

  161 OK = (200, {}, 0)
  162 
  163 
  164 def routine_scenarios(base):
! 165     out = {}
  166 
! 167     def run(name, tasks, script, once, extra=(), before=None, again=None):
! 168         work = base / name
! 169         for t in tasks:
! 170             write_task(work, *t)
! 171         if before:
! 172             before(work)
! 173         broker = Broker(script)
! 174         try:
! 175             rc = [routine(work, broker, once, extra)]
! 176             if again:
! 177                 again(work)
! 178                 rc.append(routine(work, broker, 1, extra))
  179         finally:
! 180             broker.close()
! 181         out[name] = {"rc": rc, **summary(base, work, broker.calls, "routine")}
! 182         drop_lock(work)
  183 
! 184     run("basic", TASKS, [OK], len(TASKS))
! 185     run("restart", TASKS[:1], [OK], 1, again=lambda w: write_task(w, *TASKS[3]))
  186 
! 187     def preexisting(work):
! 188         (work / "results").mkdir(parents=True, exist_ok=True)
! 189         (work / "results" / (TASKS[0][0] + ".txt")).write_text("answered elsewhere\n")
! 190     run("pre-existing result", [TASKS[0], TASKS[3]], [OK], 1, before=preexisting)
! 191     run("429 then ok", TASKS[:1], [(429, {"Retry-After": "1"}, 0), OK], 1)
! 192     run("timeout", TASKS[:1], [(200, {}, 3)], 1, extra=("--timeout", "1"))
! 193     run("provider error", TASKS[:1], [(401, {}, 0)], 1)
! 194     work = base / "cap"
! 195     for t in (TASKS[0], TASKS[3]):
! 196         write_task(work, *t)
! 197     (work / "state").mkdir(parents=True)
! 198     (work / "state" / "fires.json").write_text(json.dumps([time.time() - 10]))
! 199     broker = Broker([OK])
! 200     try:
  201         # The cap holds the next fire for most of an hour: the run is cut off, and nothing may fire.
! 202         child = subprocess.run([sys.executable, __file__, "routine-cap", str(work), broker.base], timeout=60)
  203     finally:
! 204         broker.close()
! 205     out["cap 1 per hour, full"] = {"rc": [child.returncode], **summary(base, work, broker.calls, "routine")}
! 206     drop_lock(work)
! 207     return out
  208 
  209 
  210 def codex(work, calls, extra=()):
! 211     from ag2_sparrow.adapters.codex import codex_connector as cc
! 212     log, tee = work.parent / (work.name + ".rpc"), work.parent / (work.name + ".tee.py")
! 213     tee.write_text(TEE)
! 214     stub = os.path.join(os.path.dirname(cc.__file__), "stub_app_server.py")
! 215     cc.server_argv = lambda server: [sys.executable, str(tee), str(log), stub]
! 216     with contextlib.redirect_stderr(io.StringIO()):
! 217         rc = cc.main(["--server", "stub", "--work", str(work), "--poll", "0.01", *extra])
! 218     if log.exists():
! 219         calls += [{"method": m["method"], "params": m.get("params")}
  220                   for m in map(json.loads, log.read_text().splitlines()) if m.get("method")]
! 221         log.unlink()
! 222     return rc
  223 
  224 
  225 def codex_scenarios(base):
! 226     out = {}
  227 
! 228     def run(name, tasks, once, extra=(), before=None, again=None):
! 229         work, calls = base / name, []
! 230         for t in tasks:
! 231             write_task(work, *t)
! 232         if before:
! 233             before(work)
! 234         rc = [codex(work, calls, ("--once", str(once), *extra))]
! 235         if again:
! 236             again(work)
! 237             rc.append(codex(work, calls, ("--once", "1", *extra)))
! 238         out[name] = {"rc": rc, **summary(base, work, calls, "codex")}
! 239         drop_lock(work)
  240 
! 241     run("basic", TASKS, len(TASKS))
! 242     run("restart", TASKS[:1], 1, again=lambda w: write_task(w, "task-1700000000005", "owner", "!room:ag2.space",
  243                                                             "a follow-up in the same room", 1005))
  244 
! 245     def preexisting(work):
! 246         (work / "results").mkdir(parents=True, exist_ok=True)
! 247         (work / "results" / (TASKS[0][0] + ".txt")).write_text("answered elsewhere\n")
! 248     run("pre-existing result", [TASKS[0], TASKS[3]], 1, before=preexisting)
! 249     run("turn timeout", [("task-1700000000006", "owner", "!room:ag2.space", "[[stall]] slow", 1006)], 1,
  250         extra=("--turn-timeout", "1", "--interrupt-grace", "2"))
! 251     return out
  252 
  253 
  254 def main():
! 255     if sys.argv[1] == "routine-cap":
! 256         from ag2_sparrow.adapters.claude_routine import routine_adapter as ra
! 257         threading.Timer(3, lambda: os._exit(0)).start()
! 258         with contextlib.redirect_stderr(io.StringIO()):
! 259             ra.main(["--work", sys.argv[2], "--trigger-id", "trig_01DIFF", "--allow-insecure-api-base",
  260                      "--api-base", sys.argv[3], "--once", "1", "--poll", "0.01", "--max-per-hour", "1"],
  261                     {"CLAUDE_ROUTINE_TOKEN": "t"})
! 262         return
! 263     adapter = sys.argv[1]
  264     # A base of one fixed length on every host: Codex's `chars` counts its own absolute paths.
! 265     base = Path(tempfile.mkdtemp(prefix="sparrow-diff-", dir="/tmp"))
! 266     try:
! 267         out = {"routine": routine_scenarios, "codex": codex_scenarios}[adapter](base)
  268     finally:
! 269         shutil.rmtree(base, ignore_errors=True)
! 270     print(json.dumps(out, indent=1, sort_keys=True))
  271 
  272 
  273 if __name__ == "__main__":
  274     main()

packages/ag2-sparrow/tests/test_adapter_boundaries.py

Lines 20-28

  20 def modules(root):
  21     """(dotted module name, path) for every .py under root/ag2_sparrow."""
  22     for path in sorted((root / PKG).rglob("*.py")):
  23         if "__pycache__" in path.parts:
! 24             continue
  25         yield ".".join(path.relative_to(root).with_suffix("").parts), path
  26 
  27 
  28 def resolve(module, node):

packages/ag2-sparrow/tests/test_adapter_relay_loop.py

Lines 154-167

  154             retired = []
  155             stop = threading.Event()
  156 
  157             def drain():
! 158                 while not stop.is_set() and not retired:
! 159                     if live.exists():
! 160                         _, generation = result_ready.identity_of(live)
! 161                         retired.append(result_disposal.retire_generation(
  162                             results, live, generation, lambda m: None, archive, ["task-b-1.txt"]))
! 163                     time.sleep(0.001)
  164             t = threading.Thread(target=drain, daemon=True)
  165             t.start()
  166             staged = self.stage(loop, "task-b.txt", "first\n")
  167             os.link(staged, live)           # a crash between the publish link and the stage's unlink

packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py

Lines 95-103

   95         lines.append("channel_id: " + channel)
   96     if thread:
   97         lines.append("thread_root: " + thread)
   98     if collaborator:
!  99         lines.append("collaborator: true")
  100     if kind:
  101         lines.append("channel_kind: " + kind)
  102     lines += ["access_tier: " + tier, "priority: " + priority, "task: " + body]
  103     p = Path(work) / "tasks" / (tid + ".txt")

packages/ag2-sparrow/tests/test_cloud_session_contract.py

Lines 113-129

  113             archive.mkdir(parents=True)
  114             stop, retired = threading.Event(), []
  115 
  116             def drain():
! 117                 while not stop.is_set():
! 118                     if live.exists():
! 119                         try:
! 120                             _, generation = result_ready.identity_of(live)
! 121                         except FileNotFoundError:
! 122                             continue
! 123                         retired.append(result_disposal.retire_generation(
  124                             results, live, generation, lambda m: None, archive, ["task-q-%d.txt" % len(retired)]))
! 125                     time.sleep(0.001)
  126             t = threading.Thread(target=drain, daemon=True)
  127             t.start()
  128             real_save = lc.ClaimStore.save

Lines 267-275

  267     while time.monotonic() < deadline:
  268         if predicate():
  269             return
  270         time.sleep(0.1)
! 271     raise AssertionError("timed out after %.0fs waiting for %s" % (timeout, what))
  272 
  273 
  274 @unittest.skipUnless(CONTRACT, "the task-lifecycle contract is off (TASK_LIFECYCLE_CONTRACT=0 turns it off)")
  275 @unittest.skipUnless(BRIDGE_FIXTURE.exists(), "the bridge's own broker fixture lives in the repository's src/")

Lines 314-324

  314             if bridge.poll() is None:
  315                 bridge.send_signal(signal.SIGTERM)
  316             try:
  317                 bridge.wait(timeout=10)
! 318             except subprocess.TimeoutExpired:
! 319                 bridge.kill()
! 320                 bridge.wait(timeout=10)
  321         self.addCleanup(stop)
  322         task = cloud / "tasks" / (fixture.TASK["id"] + ".txt")
  323         bounded_wait(task.exists, "the packaged bridge to write the task")
  324         with contextlib.redirect_stderr(io.StringIO()):

packages/ag2-sparrow/tests/test_cloud_session_interlock.py

Lines 36-45

  36 
  37 try:
  38     sys.path.insert(0, str(REPO_TESTS))
  39     from holder_lock_contract import FirstActContract  # noqa: E402
! 40 except ImportError:  # the package outside its repository has no contract mixin
! 41     FirstActContract = None
  42 
  43 # On now that the owner module has landed; TASK_LIFECYCLE_CONTRACT=0 turns the contract off.
  44 CONTRACT = os.environ.get("TASK_LIFECYCLE_CONTRACT", "1") == "1"
  45 GATE = unittest.skipUnless(CONTRACT, "the task-lifecycle contract is off (TASK_LIFECYCLE_CONTRACT=0)")

Lines 170-178

  170 
  171 
  172 def read_line(stream, timeout=WAIT):
  173     if not select.select([stream], [], [], timeout)[0]:
! 174         raise AssertionError("no line within %.0fs" % timeout)
  175     return stream.readline()
  176 
  177 
  178 def wait_for(predicate, what, timeout=WAIT):

Lines 180-188

  180     while time.monotonic() < deadline:
  181         if predicate():
  182             return
  183         time.sleep(0.05)
! 184     raise AssertionError("timed out after %.0fs waiting for %s" % (timeout, what))
  185 
  186 
  187 def spied(log):
  188     """The SPY log as (words, monotonic time) pairs."""

Lines 213-223

  213             if child.poll() is None:
  214                 child.send_signal(signal.SIGTERM)
  215             try:
  216                 child.wait(timeout=WAIT)
! 217             except subprocess.TimeoutExpired:
! 218                 child.kill()
! 219                 child.wait(timeout=WAIT)
  220 
  221     def spawn(self, code):
  222         child = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.DEVNULL,
  223                                  stderr=subprocess.PIPE, text=True)

Lines 234-244

  234         legacy = self.work / "state" / "connector.pid"
  235         wait_for(lambda: holder(self.work) or child.poll() is not None or legacy.exists(),
  236                  "the %s adapter to hold the dir" % kind)
  237         if child.poll() is not None:
! 238             self.fail("the %s adapter exited early: %s" % (kind, child.stderr.read()))
  239         if not holder(self.work):
! 240             self.fail("the %s adapter holds the dir by a pidfile, not by the shared lock" % kind)
  241         return child, holder(self.work)
  242 
  243     def run_main(self, fn, argv, environ=None):
  244         err = io.StringIO()

Lines 244-253

  244         err = io.StringIO()
  245         with contextlib.redirect_stderr(err):
  246             try:
  247                 rc = fn(argv, environ) if environ is not None else fn(argv)
! 248             except SystemExit as e:
! 249                 rc = e.code
  250         return rc, err.getvalue()
  251 
  252     def cloud_main(self):
  253         return self.run_main(sa.main, ["--work", str(self.work), "--session-id", "session_X", "--claude",

Lines 413-423

  413             with contextlib.suppress(OSError):
  414                 child.stdin.close()
  415             try:
  416                 child.wait(timeout=WAIT)
! 417             except subprocess.TimeoutExpired:
! 418                 child.kill()
! 419                 child.wait(timeout=WAIT)
  420 
  421     def racer(self, pause):
  422         child = subprocess.Popen([sys.executable, "-c", RACE % (str(PKG_ROOT), SET_HOME, str(self.work), pause,
  423                                                                 LOCK_HOME)],

Lines 444-452

  444 
  445 def first_act_case(adapter, name):
  446     """The parent's FirstActContract against one adapter's real CLI (through first_act_runner.py)."""
  447     if FirstActContract is None:
! 448         return unittest.skip("the repository's holder_lock contract mixin is not here")(type(
  449             "Skipped", (unittest.TestCase,), {}))
  450 
  451     class Case(FirstActContract, unittest.TestCase):
  452         module, module_kind = holder_lock, "vendored"

packages/ag2-sparrow/tests/test_task_lifecycle.py

Lines 54-62

  54 
  55 
  56 def src_copy():
  57     if not (REPO_SRC / "task_lifecycle.py").is_file():
! 58         return None
  59     spec = importlib.util.spec_from_file_location("task_lifecycle_src", REPO_SRC / "task_lifecycle.py")
  60     mod = importlib.util.module_from_spec(spec)
  61     spec.loader.exec_module(mod)
  62     return mod

Lines 76-88

  76 
  77 def fd_path(fd):
  78     try:
  79         if sys.platform == "darwin":
! 80             import fcntl
! 81             return fcntl.fcntl(fd, fcntl.F_GETPATH, bytes(1024)).split(b"\0", 1)[0].decode()
  82         return os.readlink("/proc/self/fd/%d" % fd)
! 83     except OSError:
! 84         return ""
  85 
  86 
  87 def fd_name(fd):
  88     """The name of the file an fd refers to; a dot-temp shows as <temp>."""

Lines 96-108

   96     st = os.fstat(fd)
   97     path = fd_path(fd)
   98     try:
   99         here = os.stat(path)
! 100     except OSError:
! 101         here = None
  102     if here is not None and (here.st_dev, here.st_ino) == (st.st_dev, st.st_ino):
  103         return os.path.basename(path)
! 104     return "?%d:%d" % (st.st_dev, st.st_ino)
  105 
  106 
  107 def durable_events(lc, events):
  108     """Patches recording each fsync (a directory by descriptor identity), rename and no-replace rename."""

Lines 158-166

  158         """Make `path` unreadable; skipped where permissions are not enforced (root)."""
  159         os.chmod(path, 0)
  160         self.addCleanup(os.chmod, path, 0o700)
  161         if os.access(path, os.R_OK):
! 162             self.skipTest("permissions are not enforced for this user")
  163 
  164     def calls(self):
  165         p = Path(self.tmp.name + "-bin") / "claude.calls"
  166         return len(p.read_text().splitlines()) if p.exists() else 0

Lines 280-288

  280 def read_line(child, timeout=10.0):
  281     """One line from a child's stdout, or AssertionError after `timeout`: a red run never hangs."""
  282     ready, _, _ = select.select([child.stdout], [], [], timeout)
  283     if not ready:
! 284         raise AssertionError("no line from the child within %.0fs" % timeout)
  285     return child.stdout.readline()
  286 
  287 
  288 class LockTest(Base):

Lines 303-311

  303         child.kill()
  304         child.wait(10)
  305         deadline = time.monotonic() + 10
  306         while time.monotonic() < deadline and task_io.pid_alive(pid):
! 307             time.sleep(0.02)
  308         rc, err = self.main()
  309         self.assertEqual(rc, 0, err)
  310         self.assertIsInstance(holder_lock.read_holder(str(self.work)), holder_lock.Missing, "released on exit")

Lines 427-435

  427         writer.send_signal(signal.SIGTERM)
  428         writer.wait(10)
  429         deadline = time.monotonic() + 10
  430         while time.monotonic() < deadline and task_io.pid_alive(int(pid)):
! 431             time.sleep(0.02)
  432         pidfile = state / "connector.pid"
  433         self.assertEqual(pidfile.read_text(), "%s\n" % pid, "SIGTERMed, its finally never ran")
  434         time.sleep(1.1)
  435         self.task()

Lines 738-746

  738     def test_the_modules_read_no_legacy_file(self):
  739         for path in (REPO_SRC / "task_lifecycle.py", PKG_ROOT / "ag2_sparrow" / "task_lifecycle.py",
  740                      ADAPTERS / "claude_cloud_session" / "session_adapter.py"):
  741             if not path.exists():
! 742                 continue
  743             strings = [n.value for n in ast.walk(ast.parse(path.read_text()))
  744                        if isinstance(n, ast.Constant) and isinstance(n.value, str)]
  745             for word in ("sends.json", "fires.json", "sending", ".staged"):
  746                 self.assertFalse([v for v in strings if word in v], (path.name, word))

Lines 1174-1186

  1174         busy = []
  1175         real = life.read_final
  1176 
  1177         def other_thread_tries():
! 1178             try:
! 1179                 with result_disposal.locked(self.results):
! 1180                     busy.append(False)
! 1181             except result_disposal.DisposalBusy:
! 1182                 busy.append(True)
  1183 
  1184         def check(final):
  1185             t = threading.Thread(target=other_thread_tries)
  1186             t.start()

Lines 1456-1464

  1456         def half_then_crash(fd, data):
  1457             if data.startswith(b"task-a\n"):
  1458                 real_write(fd, data[:3])
  1459                 raise Crash()
! 1460             return real_write(fd, data)
  1461         with mock.patch.object(self.lc.os, "write", half_then_crash), self.assertRaises(Crash):
  1462             life.publish_and_settle("task-a")
  1463         self.assertEqual((self.work / "state" / "settled-ids").read_text(), "tas")
  1464         self.life().recover()

Lines 1830-1838

  1830             hits += [ast.unparse(n) for n in ast.walk(tree) if isinstance(n, (ast.Import, ast.ImportFrom))
  1831                      and "holder_lock" in ast.unparse(n)]
  1832             hits += [n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute) and n.attr == "holder_lock"]
  1833             if hits:
! 1834                 found[str(path.relative_to(PKG_ROOT.parents[1]))] = hits
  1835         self.assertEqual(found, {})
  1836 
  1837 
  1838 # A pre-holder_lock revision's own pidfile, under the work root; the shared lock keeps nothing there.

Lines 1893-1901

  1893             elif isinstance(node, ast.ImportFrom):
  1894                 found += [(node.module or "") + ":" + a.name for a in node.names
  1895                           if "task_lifecycle" in (node.module or "") + a.name]
  1896             elif isinstance(node, ast.Name) and "task_lifecycle" in node.id:
! 1897                 found.append(node.id)
  1898             elif isinstance(node, ast.Attribute) and "task_lifecycle" in node.attr:
  1899                 found.append(node.attr)
  1900             elif isinstance(node, ast.Constant) and isinstance(node.value, str) and "task_lifecycle" in node.value:
  1901                 found.append(node.value[:60])

Lines 1937-1945

  1937 
  1938     def test_the_owner_imports_no_adapter(self):
  1939         for path in (REPO_SRC / "task_lifecycle.py", PKG_ROOT / "ag2_sparrow" / "task_lifecycle.py"):
  1940             if not path.exists():
! 1941                 continue
  1942             tree = ast.parse(path.read_text())
  1943             imported = [ast.unparse(n) for n in ast.walk(tree) if isinstance(n, (ast.Import, ast.ImportFrom))]
  1944             self.assertFalse([i for i in imported if "adapters" in i], imported)

src/task_lifecycle.py

Lines 153-163

  153     """The first path under `root` (relative), descending into non-empty directories."""
  154     for name in sorted(os.listdir(os.path.join(root, rel) if rel else root)):
  155         path = os.path.join(rel, name) if rel else name
  156         if os.path.isdir(os.path.join(root, path)) and not os.path.islink(os.path.join(root, path)):
! 157             return _first_entry(root, path) or path
  158         return path
! 159     return None
  160 
  161 
  162 def _init_step(name):
  163     """A test seam between the first-init steps; production does nothing here."""

Lines 176-198

  176     """No marker yet: the work dir may hold the bridge's tasks/ and results/ and an interrupted
  177     init's own state/ (an empty ledger, an empty claims/, one marker temp)."""
  178     format_temps = 0
  179     for name in sorted(os.listdir(work)):
! 180         if name in ("tasks", "results") and _is_type(os.path.join(work, name), "dir"):
! 181             continue
! 182         if name != "state" or not _is_type(os.path.join(work, name), "dir"):
! 183             path = os.path.join(work, name)
! 184             return (_first_entry(work, name) if _is_type(path, "dir") else None) or name
! 185         for inner in sorted(os.listdir(os.path.join(work, "state"))):
! 186             path = os.path.join(work, "state", inner)
! 187             if inner == "settled-ids" and _is_type(path, "file") and not os.lstat(path).st_size:
! 188                 continue
! 189             if inner == "claims" and _is_type(path, "dir") and not os.listdir(path):
! 190                 continue
! 191             if FORMAT_TEMP.fullmatch(inner) and _is_type(path, "file") and format_temps == 0:
! 192                 format_temps += 1
! 193                 continue
! 194             return (_first_entry(work, os.path.join("state", inner)) if os.path.isdir(path) else None) \
  195                 or os.path.join("state", inner)
  196     return None
  197 

Lines 212-220

  212     fsync_dir(state)
  213     _init_step("claims")
  214     for name in os.listdir(state):
  215         if FORMAT_TEMP.fullmatch(name):
! 216             os.unlink(os.path.join(state, name))
  217     tmp = os.path.join(state, ".format.%d.%s.tmp" % (os.getpid(), uuid.uuid4().hex[:6]))
  218     fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0), 0o644)
  219     try:
  220         os.write(fd, (FORMAT + "\n").encode("utf-8"))

Lines 237-245

  237     try:
  238         if not os.path.lexists(marker):
  239             entry = _state_entry_refusal(work)
  240             if entry:
! 241                 return ("refusing to start: %s is not a fresh work dir for this adapter (it holds %s); "
  242                         "this adapter serves only a new, dedicated work dir" % (work, entry))
  243             _first_init(work)
  244             return None
  245         with open(marker, encoding="utf-8") as f:

Lines 244-271

  244             return None
  245         with open(marker, encoding="utf-8") as f:
  246             found = f.read().strip()
  247         if found != FORMAT:
! 248             return "refusing to start: %s says %r, this binary serves %r" % (marker, found, FORMAT)
  249         if not _is_type(state, "dir"):
  250             return "refusing to start: %s is not a directory" % state
! 251         for name, kind in (("settled-ids", "file"), ("claims", "dir")):
! 252             path = os.path.join(state, name)
! 253             if not os.path.lexists(path):
! 254                 return "refusing to start: %s is marked but %s is missing" % (state, name)
! 255             if not _is_type(path, kind):
! 256                 return "refusing to start: state/%s in %s is not a plain %s" % (name, state, kind)
! 257         for name in sorted(os.listdir(state)):
! 258             if name in STATE_ALLOWED:
! 259                 continue
! 260             if name.startswith(".") and name.endswith(".tmp") and any(name[1:].startswith(a + ".")
  261                                                                       for a in STATE_ALLOWED - {"format"}):
! 262                 os.unlink(os.path.join(state, name))
! 263                 continue
! 264             return "refusing to start: state/%s is not this adapter's state (in %s)" % (name, work)
! 265     except OSError as e:
! 266         return "refusing to start: cannot check %s: %s" % (work, e)
! 267     return None
  268 
  269 
  270 def probe_moves(directory):
  271     """Refusal line unless rename, hard link and the no-replace move publication uses all work in `directory`."""

Lines 542-551

  542             d.mkdir(parents=True, exist_ok=True)
  543             if new:
  544                 fsync_dir(self.root)
  545             write_durable(d / "claim.json", json.dumps(rec, sort_keys=True).encode("utf-8"))
! 546         except OSError as e:
! 547             raise _state_io("write", d / "claim.json", e)
  548 
  549     def stage(self, task_id, body):
  550         try:
  551             write_durable(self.dir(task_id) / "body", body)

Lines 579-587

  579     def ids(self):
  580         try:
  581             return sorted(n for n in os.listdir(self.root) if not n.startswith("."))
  582         except FileNotFoundError:
! 583             return []
  584         except OSError as e:
  585             raise _state_io("list", self.root, e)
  586 

⚠️ Not measured

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

  • packages/ag2-sparrow/tests/first_act_runner.py
  • packages/ag2-sparrow/tests/test_claude_code_adapter.py
  • packages/ag2-sparrow/tests/test_wheel_contents.py

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

Reviewed exact head 42dcabc1c4b8781baadbe4cfce6a321c4d8bae8d. The CI/CLA checks are green, the focused suites pass, and the exact-head live witness is useful. I am requesting changes for the following merge blockers.

  1. P1 — a contradictory queue receipt is retried, breaking the advertised at-most-once invariant. session_adapter.py:65-71 only accepts {"ok": true} when the exit code is zero, then lets a matching DNS string classify the same invocation as provably unsent. I reproduced this with stdout containing an ok receipt, exit 1, and stderr containing getaddrinfo ENOTFOUND: the fake CLI was invoked twice for one task. A positive receipt plus a later error is contradictory/ambiguous, not proof of non-delivery; it must never be retried. Please add this exact conflict case to the retry tests.

  2. P1 — every task body is exposed in process argv. session_adapter.py:56-60 passes the complete prompt as an argument, and the test at test_claude_cloud_session_adapter.py:210-228 pins that behavior. With a long-running stand-in CLI, a synthetic secret marker was visible in ps -axo command= (child_exposes_secret=True). These tasks can contain private room or connected-account data. Pass the prompt through stdin and keep argv metadata-only.

  3. P1 — the persistent session cannot enforce the repository's audience/data-origin policy. claude_routine/routine_adapter.py:45-63 sends room/thread/tier/collaborator/task ID but omits trusted channel_kind; session-prompt.md:21-28 then requires every owner-tier answer to be posted into the source room and forbids DM rerouting. That conflicts with AGENTS.md:243-260, which requires owner-only work and connected-account data to go to the owner's DM even when requested from a shared room. Persistence makes cross-room remembered context an additional exposure. Either restrict a session to an explicitly single owner-DM audience, or carry trusted audience metadata and provide a policy-compliant DM routing path; add a shared-room owner-task test.

  4. P1 — the child silently drops the configured Claude credential boundary. session_adapter.py:27,34-36 omits CLAUDE_CONFIG_DIR; the direct check returns no value even when the caller supplies one. src/claude_config_dir.sh:1-3,31-36 explicitly refuses this fallback because an unset value selects a different user-level credential store. Preserve the caller's validated config directory and test it, while continuing to exclude unrelated secrets.

  5. P2 — proven-unsent attempts consume the documented queued-turn limit. session_adapter.py:139-146 records every invocation before inspecting out.kind. With --max-per-hour 1, one DNS failure followed by a successful retry produced two calls but advanced the fake clock by 3,600 seconds, despite the configured retry backoff being 2 seconds and zero turns having been queued before the retry. Record only accepted/ambiguous turns, or rename and document this as an attempt limit; the current implementation contradicts README.md:75-76 and the flag help.

  6. P2 — the adapter ignores the task protocol's priority ordering. The inherited pending() at claude_routine/routine_adapter.py:202-215 sorts only by mtime. An older low task was selected before a newer urgent task, opposite the production contract in src/task_priority.py:92-104 and local_task_protocol.py:112-114. This adapter consumes the shared task-* queue, so it must use the shared/vendored priority policy and test urgent-before-low with FIFO inside a tier.

  7. Architecture blocker — the new adapter imports and subclasses another adapter's private implementation. session_adapter.py:20-21,82 depends directly on claude_routine.routine_adapter. docs/architecture-boundaries.md:97-106 explicitly disallows adapter-to-adapter private implementation dependencies and puts shared provider-neutral behavior in core or an adapter library. Extract the task/result loop, payload/result helpers, and common rate/state policy to a non-private shared owner, then pin both adapters' delegation. This is also the right place to repair priority ordering rather than adding another policy copy.

Verification performed at this head: review preflight and hardcoded-path checks passed; py_compile passed; the new adapter suite passed 25/25; the routine regression suite passed 36/36; wheel/package checks and all remote CI are green. The 0.5-second timeout test also failed once only under concurrent local load and passed alone; please harden that timing while updating the failure-path tests.

…ience, shared relay loop

- A stdout receipt with "ok": true means queued whatever the exit code or
  stderr says; only a receipt-less, provably unsent call is retried.
- The turn text goes on stdin; argv is metadata only.
- The routing line carries the attested channel_kind and an optional
  owner_dm (--owner-dm); the standing instructions route owner-only answers
  from a shared room to the owner's DM, or decline in the room.
- CLAUDE_CONFIG_DIR passes through to the CLI when the caller sets it.
- The hourly cap counts queued and ambiguous turns only.
- The shared task/result loop, payload layout, result lines and rate limiter
  move to adapters/relay_loop.py; both Claude adapters delegate to it, and
  neither imports the other. Pending tasks are taken in task-priority order
  through a vendored copy of src/task_priority.py, which now imports its
  protocol module relatively inside the package.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 2 at ee26fd0 addresses all seven items; the PR body is updated. Witness status at this head, stated plainly:

Adapter side ran. With the new standing prompt installed in the prepared session (it replied ready to the previous version; the new one was queued), two owner tasks from a shared test room with --owner-dm set were queued by the adapter: queue -> … ok in 1 s each, both results [no-send] plus the session URL, stdin carrying the text and argv metadata-only.

Session side is blocked, and that is a finding. Before those two turns, a probe turn I sent asked the session to call room_list; that tool raised a permission prompt in the cloud session (07:59:49Z in its event log), while ag2_whoami, room_actions_describe and room_action_execute had run without one. Nothing answers that prompt from the CLI: claude --cloud <session_id> returns "Attaching to an existing cloud session is not enabled for your account", and claude --teleport pulls the session to the local machine instead. Every turn queued after the prompt waits behind it, so the two witness turns have not run. Only the owner can clear it, from the session's web page; the witness at the final head is owed after that.

Consequence for the adapter: a cloud session that raises a permission prompt stalls the whole queue silently, and this adapter cannot detect it (the CLI only reports "queued"). I'm adding to the standing prompt a rule to use only the three tools that ran without prompting, and to the README the limit and the owner-side fix (clear the prompt, or set the session's permission mode in the web UI), with an issue for a liveness check. That lands as one more commit; the final witness follows at that head.

…rmission prompt

A permission prompt inside the cloud session holds every turn queued after
it, and nothing on the CLI side can answer it. The standing instructions now
allow only ag2_whoami, room_actions_describe and room_action_execute and
forbid every other tool; the README names the stall, that the adapter cannot
see it, and the owner-side fixes on the session page.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

The follow-up is in: 60a1f25 restricts the standing prompt to the three tools that ran without a permission prompt (whoami, room_actions_describe, room_action_execute), forbids the rest, documents the limit in the README, and pins the allowlist with a test. This is the head to review. The final live witness follows once the owner clears the prompt in the prepared session.

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

Re-reviewed current head 60a1f25a1a1a7a5121109a146069ffb50e6e33b0. Round 2 does resolve the original argv leak, CLAUDE_CONFIG_DIR propagation, the ordinary proven-unsent rate-accounting case, task priority, and the direct cloud-session → routine-adapter dependency. CI and CLA are green. Changes are still requested for the following blockers.

  1. P1 — a positive queue receipt can still be retried. _receipt() at session_adapter.py:55-65 returns the first parseable object while scanning stdout from the end. With stdout {"ok":true,"url":"…"}\n{"error":"diagnostic"}\n, exit 1, and stderr getaddrinfo ENOTFOUND, queue_turn() returns retry; the end-to-end fake then runs twice and the result points at the second turn. The positive-receipt-only control returns ok. This contradicts the PR's “any stdout receipt” contract and preserves the duplicate-post risk. Scan all decoded JSON records for any ok is True before considering retry classification, and add the positive-receipt-followed-by-diagnostic case.

  2. P2 — a crash loses the persisted hourly-cap accounting. At session_adapter.py:146-153, the marker is durable before the CLI call, but limiter.record() happens only after the call returns. I injected a crash after the fake CLI queued turn 1 and before record(): sends.json did not exist and the sending marker did. On restart the marker correctly made turn 1 ambiguous, but with --max-per-hour 1 turn 2 queued immediately (calls=2, fake elapsed time 0.0). README.md:89-91 says queued or possibly queued turns count and survive restart. Persist this transition idempotently so repeated crashes cannot omit or double-count it, and add this crash-window test.

  3. P1 — the required privacy route contradicts the per-task send instruction. session_adapter.py:25-26 tells the session to “post exactly once,” and session-prompt.md:9-12 says “one message, one reply,” but the private-answer branch at session-prompt.md:25-35 requires two posts: the answer in owner_dm plus “I sent it to you in our DM.” in the source room. This ambiguity sits on the privacy boundary fixed in round 2. Make the reminder and standing prompt agree on the exact allowed message count, pin that invariant, and exercise the two-destination path in the final live witness.

  4. P2 — the permission-stall mitigation removes the capability the PR advertises. session-prompt.md:37-40 now forbids every tool and command except three AG2 Space calls. That prevents file/code/web/connected-account work, while README.md:3-5 and the PR summary still present a persistent agent with the full Claude Code toolset, and the audience section describes connected-account answers. Either establish a permission configuration that retains the intended tools without stalling, or explicitly narrow the adapter's supported task scope and claims. A source-text allowlist test proves only the restriction, not that representative tasks can complete.

  5. Architecture blocker — the shared owner still depends on a concrete adapter's private implementation. The cloud → routine import is gone, but relay_loop.py:17 imports task/result helpers from adapters/codex/codex_connector.py; both Claude adapters also import that concrete adapter at session_adapter.py:20 and routine_adapter.py:23. This still violates docs/architecture-boundaries.md:97-106. The new structural test only forbids cloud ↔ routine imports, so it cannot catch this dependency. Move the generic task parsing/ledger/atomic-write/lock/note contracts to a non-private shared owner and have all three adapters delegate to it.

Verification at this SHA: 37 cloud-session tests, 8 shared-loop tests, and 36 routine tests passed by name; the wheel/sdist smoke, vendoring check, py_compile, diff scan, and all remote CI/CLA checks passed. The required exact-head live session-side witness is still explicitly owed; approval also remains blocked on that real round trip under REVIEW.md §15.

…ap, one answer, no adapter imports another

- Any JSON record with "ok": true anywhere in the CLI's stdout or stderr
  means queued; a later diagnostic line, exit code or DNS error never
  turns it into a retry.
- The hourly cap entry is written before the CLI call, keyed by task id
  (idempotent), and removed only when the call proves it was never sent,
  so a crash after a send keeps the count and a restart cannot double it.
- The standing prompt pins the message count: one answer, to the room or
  to owner_dm, plus only the one DM pointer line in the room.
- The session keeps Claude Code's own tools; only the AG2 Space connector
  is limited to the three tools that did not prompt.
- Task-file I/O (attested headers, parse, atomic write, done ledger,
  pidfile, non-owner note) moves from the Codex adapter to package-level
  ag2_sparrow/task_io.py, and the relay loop moves to ag2_sparrow/relay_loop.py.
  Codex, Hatch and both Claude adapters import from there; a structural
  test fails on any adapter importing another.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 3 at ed6fb35, for the round-2 review (#5660 (review)):

  1. Every JSON record in stdout and stderr is checked; any "ok": true means queued, never retried. Test: receipt, then another JSON line, exit 1, DNS error → one call (fails at 60a1f25 with 2 != 1).
  2. The hourly-cap entry is written before the CLI call, keyed by task id and idempotent; removed only when the call proves nothing was sent; a restart re-records as a no-op. Crash-window test added (errors at 60a1f25).
  3. The standing prompt now says: one answer, to exactly one place (the room or owner_dm); when it went to owner_dm, exactly one extra line in the room, "I sent it to you in our DM."; never two answers. Pinned by a prompt test.
  4. Wording made exact in README, prompt and body: the session keeps Claude Code's own tools; only the AG2 Space connector is limited to the three tools that did not prompt; any prompting tool stalls the queue (the liveness issue is claude cloud-session adapter: a permission prompt in the session stalls the queue undetected; add a liveness check #5661).
  5. The shared task-file helpers moved to package-level ag2_sparrow/task_io.py; relay_loop moved to package level too; the Codex adapter, Hatch's task_fields, relay_loop and both Claude adapters import from there. The structural test now scans every module under adapters/ and fails on any adapter importing another.

Stated openly: task_io.non_owner_note() still reads the note's text from the Hatch adapter's script file by path (a file read, not an import), so the two copies cannot drift; if you count that as a dependency, say so and it moves too. Suites at this head: cloud-session 41 OK, routine 36 OK, relay_loop 10 OK, codex connector 43 OK (two suites needed a second run after fixture updates, noted in the body); Hatch and wheel checks left to CI.

The exact-head witness, including the two-destination DM path, follows once the owner clears the permission prompt in the prepared session; the updated standing prompt is already queued there.

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

Re-reviewed exact head ed6fb353677412afc4dba3c62d7ece3b5bd4ee22. The positive-receipt scan is fixed, the one-answer/one-pointer and connector-only tool wording are now internally consistent, and the Python adapter-to-adapter imports are gone. All current CI and CLA checks are green. Changes are still requested for these blockers.

  1. P2 — crash recovery waits behind, then renews, its own hourly-cap entry. RelayLoop.next_ready() applies limiter.wait_seconds() at relay_loop.py:190-200 before the cloud adapter can inspect the durable sending marker at session_adapter.py:138-143. With a fixed clock and --max-per-hour 1, I queued one fake turn and crashed after the CLI returned. Restart had the task and marker, but next_ready() returned (None, 3600.0) and no result existed until an hour elapsed. At that point the expired entry was pruned and the marker branch's record(task_id) added it again at the recovery time, so the next real task was held for another 3600 seconds. A crash in the adjacent window between record() and mark_sending() likewise delays a definitely-unsent task for an hour. The new crash helper restarts under the default cap of 60 (test_claude_cloud_session_adapter.py:511-523), so it does not exercise the full-cap recovery path. Marker disposition must bypass send pacing/rate gates without refreshing an old reservation; please pin both full-cap restart windows.

  2. P2 — the pre-send reservation no longer anchors the documented minimum gap to the queued outcome. In a fake call that advanced the adapter clock by 10 seconds, the persisted entry stayed at 1800000000.0, the positive queue receipt arrived at 1800000010.0, and with --min-gap-seconds 2 the next task was immediately ready. RateLimiter.record(key) is idempotent, so the success/ambiguous path never confirms the reservation at the outcome time. The README promises spacing between queued turns, not merely between subprocess starts; retain the crash-safe reservation, then finalize its timestamp once the outcome is known.

  3. P2 architecture — the shared owner still depends on a concrete adapter. Package-level task_io.py:14,37-50 names, opens, and parses adapters/hatch/ag2-sparrow-hatch.sh. That is still shared/core → adapter dependency by filesystem lookup, even though it is not a Python import, and it conflicts with docs/architecture-boundaries.md's dependency direction. The new AST scan cannot see it because it only examines imports under adapters/. Make the non-owner policy canonical in the shared layer/resource and have Hatch consume/render that owner, rather than having shared code locate Hatch.

Approval is independently blocked on the exact-head live witness: the latest author note still says the ed6fb353 two-destination DM path “follows once the owner clears the permission prompt.” The source assertions pin text, but under REVIEW.md §15 this delivery-loop change needs a real post-restart round trip at this SHA, including the DM answer plus the single source-room pointer.

Verification at this SHA: 41 cloud-session, 10 shared-loop, 36 routine, and 43 Codex tests passed locally; independent QA also passed the 109 Hatch tests. Wheel/sdist out-of-tree smoke, vendoring sync, py_compile, diff --check, and the review checks passed. Remote CI and CLA are green. The branch is opt-in, which limits existing-user blast radius, but its recovery path is not merge-ready until the two code blockers above are fixed and the exact-head witness is attached.

… counts from the outcome, one non-owner note owner

- A task that already holds a cap reservation or a pre-send marker is
  settled first on restart and skips the hourly cap and the minimum gap;
  settling never adds an entry or moves an old one.
- Once a turn's outcome is known (queued or ambiguous), its reservation
  moves to the outcome time, so --min-gap-seconds counts from the queue,
  not from the call's start.
- The non-owner policy text is canonical in ag2_sparrow.task_io
  (NON_OWNER_NOTE, printed by `python -m ag2_sparrow.task_io
  --non-owner-note`). The Hatch hook reads it from there at run time, the
  Claude Code mod pins it against the shared owner, and task_io no longer
  opens any adapter file. Structural tests fail on a shared module naming
  an adapters/ path and on any adapter naming another adapter's path.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 4 at ea61bcf, for the round-3 review (#5660 (review)):

  1. Recovery after a restart settles a task that already holds a reservation or a pre-send marker first, outside the hourly cap and the minimum gap, and adds no second entry and no newer timestamp. Both full-cap restart windows are pinned (each failed at ed6fb35 by waiting a full hour: 1800003600.0 != 1800000100.0).
  2. The reservation keeps its crash-safe pre-send write, and its timestamp moves to the moment the outcome is known; a proven-unsent attempt still removes it. Test with a slow fake CLI and --min-gap-seconds 2 (failed at ed6fb35: 1800000010.0 not >= 1800000012.0).
  3. The non-owner note is canonical in ag2_sparrow/task_io.py (python -m ag2_sparrow.task_io --non-owner-note prints it). The Hatch hook renders it from there at run time, and the Claude Code mod pins the same text with a CI test holding it equal; task_io opens no adapter file. New structural tests fail on a package-level module naming an adapter path, and on any adapter's code naming another adapter's path. The previously pinned text remains as a test fixture: no byte changed.

Suites at this head: cloud-session 44 OK; codex connector 43 OK; claude-code adapter 10 OK; relay_loop 13 OK; Hatch 111 with 1 failure (test_no_systemd_call_can_prompt_for_a_password), which fails identically on origin/main on this host and passed in your run. Two suites needed a second run after fixing my own new tests, noted in the body. Not run locally: the routine suite and the mod's TypeScript tests (CI). One operational note in the body: existing Hatch installs keep their copied hook until install is re-run.

The exact-head witness with the DM-plus-pointer path follows once the owner clears the permission prompt in the prepared session.

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

Re-reviewed exact head ea61bcf8676a8daa277a79ac2cce8adfc4c7da4e. The three advertised round-3 cases are fixed in their direct paths: both full-cap recovery tests settle immediately without double-counting, ordinary queued/ambiguous outcomes move the reservation timestamp, and task_io.py now owns the non-owner note while Hatch consumes it. Changes are still requested for the following current-head blockers.

  1. P2 — a crash can turn a proven-unsent attempt into a suppressed retry. In session_adapter.py:158-160, the retry path removes the limiter reservation before clearing the durable sending marker. I made the fake CLI return getaddrinfo ENOTFOUND, then injected a crash in _clear(). The exact-head output was:

    after_crash calls=1 marker=True sends=[] result=False
    after_restart calls=1 result='This message may or may not have reached the cloud agent (interrupted); it was not retried.'
    

    The service was provably never reached, but restart sees only the surviving marker at session_adapter.py:141-145 and suppresses the safe retry promised by the failure table. Clear/transition the marker before releasing the keyed reservation (or persist an equivalent atomic state transition), and add a crash-between-cleanup-steps regression test.

  2. P2 — the crash-before-touch() window still under-enforces both pacing guarantees. A reservation is written before the call, but if the CLI completes and the process dies before session_adapter.py:164, restart settles the marker without finalizing that entry. With a fake call that took 10 seconds, I measured:

    cap=60 gap=2 first_done=1800000010 next_queued=1800000010 delta=0
    cap=1 gap=0 first_done=1800000010 next_queued=1800003600 delta=3590
    

    Thus the next turn can queue with zero seconds of the documented two-second gap, or two possibly queued turns can fall inside the documented sliding hour. The new recovery tests stop after settling the marked task, so they miss the following turn. Finalize a possibly-sent interrupted turn conservatively before admitting later work, and pin the next-turn behavior for both gates.

  3. P2 architecture guard — the new shared-to-adapter scan does not catch imports. test_adapter_relay_loop.py:204-211 checks string constants only and scans only top-level ag2_sparrow/*.py. The forbidden dependency from .adapters.hatch import cli has no string constants and therefore reports no violation; a shared subpackage is not scanned at all. The implementation is currently pointed in the correct direction, but the claimed structural regression guard does not enforce that boundary. Scan Import/ImportFrom nodes recursively across non-adapter package modules as well as path strings, with a positive control for the forbidden import.

Two merge gates also remain open:

  • The exact-head post-restart live witness, including the owner-DM answer plus one source-room pointer, is still explicitly owed. Under REVIEW.md §15, source assertions do not replace that delivery-loop witness.
  • Required CI is red. The underlying lints + typecheck failure is the cumulative review check: task_io.py:13-15 forms a three-line comment block over the two-line cap. The tsc + tests (clean install) aggregate fails only because that dependency failed. CLA is green.

Verification at this SHA: 44 cloud-session, 13 relay-loop, 36 routine, 43 Codex, 10 Claude Code, and 111 Hatch tests pass locally. Independent QA reproduced the direct round-3 fixes and the CI failure; remote diff coverage and the remaining test lanes pass. The checkout stayed clean and the remote head was still exactly ea61bcf8 immediately before this review.

…ze, import-walking boundary guard

- The task_io comment block is cut to the two-line cap.
- The pre-send marker now carries a phase. A proven-unsent call rewrites
  it to "unsent" before releasing its reservation, so a crash between the
  two steps restarts into exactly one retry instead of a suppressed one.
- A queued or ambiguous outcome rewrites it to "outcome" after the
  reservation moves to the outcome time. A restart that finds "sending"
  (outcome never recorded) moves the one entry to the restart time, so
  the next turn still waits the full gap and the hour.
- The boundary guard moves to test_adapter_boundaries.py. It walks every
  Import/ImportFrom in every module of the package, subpackages included,
  resolving relative imports, and keeps the path-string scans. Positive
  controls plant each forbidden shape in a throwaway tree.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 5 at 8d035a4, for the round-4 review (#5660 (review)):

  • CI: the task_io.py comment is two lines. My earlier local checks piped only the incremental diff into review-checks.sh; CI's form (git diff --cached origin/main | bash scripts/review-checks.sh) now passes locally too.
  1. The pre-send marker carries a phase (sending → outcome, or unsent written before a proven-unsent reservation is released). A crash between the unsent mark and the release restarts into exactly one retry; the order is pinned (failed at ea61bcf: '…pid…' != 'unsent').
  2. On restart, a marker still at sending means the outcome was never recorded: its single entry moves to the restart time (the latest the turn could have been queued), no second entry. The next turn's timing is pinned for both gates (at ea61bcf: 1800000015.0 not >= 1800000017.0 for the gap, 1800003600.0 not >= 1800003615.0 for the hour).
  3. The boundary guard is now tests/test_adapter_boundaries.py (with a CI wrapper): it walks every import in every module of the package, subpackages included, resolving relative imports; fails on a shared module importing any adapter and on any adapter importing another; keeps both path-string scans; positive controls in a throwaway tree catch a function-local relative adapter import inside a shared subpackage, an absolute adapter import, and a shared path string.

Suites at this head: cloud-session 49 OK (second run, after fixing a new test of mine that read sends.json after the hour had pruned it), relay_loop 8 OK, boundaries 8 OK, codex connector 43 OK. The exact-head witness with the DM-plus-pointer path follows once the owner clears the permission prompt in the prepared session; its standing prompt is unchanged since ed6fb35 and already queued there.

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

Re-reviewed exact head 8d035a4a6703a8df3039389b6148c5483931c7e5. The three round-4 findings are fixed for the committed cases: a retained task recovers from the unsent phase, an interrupted sending phase conservatively protects both later pacing gates, the recursive import guard catches the requested shared-to-adapter shapes, and required CI/CLA are green. Changes are still requested for two adjacent crash/lifecycle blockers.

  1. P2 — cancelling a task after the durable unsent mark strands a proven-unsent cap reservation. session_adapter.py:178-180 intentionally persists unsent before forget(), but recovery is driven only from names returned by RelayLoop.pending() (relay_loop.py:150-158). If the process crashes before forget() and the task is then cancelled/removed, no later pass visits its marker. Exact-head fake-CLI repro:

    marker='unsent' before=[[1800000000.0, 'task-cancelled']] calls_before_restart=1
    restart_wait=3590 calls_total=2 after=[[1800003600.0, 'task-next']]
    

    The DNS failure proved no turn queued, yet at --max-per-hour=1 unrelated work waited the rest of the hour, and the orphan marker remained. This contradicts the documented “proven-unsent attempts do not count” behavior. The committed test retains the original task, so it cannot exercise this path. Reconcile unsent reservations/markers independently of pending task files and pin cancellation/removal followed by new work.

  2. P2 — result publication, the done ledger, and marker cleanup are not one recoverable handoff. RelayLoop.finish() publishes the result before record_done() (relay_loop.py:160-163), and the cloud adapter clears its phase marker only after that returns (session_adapter.py:131-133). A result drain is allowed to claim the file as soon as it appears. I claimed the first result at the record_done() boundary and injected a crash; restart produced:

    after_crash claimed='[no-send]\n# claude cloud session: ...turn=1' marker='outcome' live_result=False calls=1
    after_restart second='This message may or may not have reached the cloud agent (interrupted); it was not retried.' calls=1
    

    Thus one successfully queued turn publishes two contradictory local outcomes. I did not establish a second remote message—the gateway outbox may reject/dedupe the replacement—but the task/result contract is already inconsistent, and --result-mode echo or another immediate drain can expose it. Make this settlement recoverable across an immediate production-style result claim, and add a concurrent drain/crash regression test against the production writer/consumer boundary.

Approval is also independently blocked by the explicitly owed exact-head live witness under REVIEW.md §15. The earlier witness is from 42dcabc1; 8d035a4a still lacks the real post-restart owner-DM answer plus single source-room pointer.

Verification at this SHA: 49 cloud-session, 8 relay-loop, 8 boundary, 36 routine, 43 Codex, 10 Claude Code, 111 Hatch, wheel/sdist, vendoring sync, diff --check, and cumulative review checks pass locally. Independent QA additionally passed the after-forget/before-clear and after-outcome/before-finish phase probes. Remote required CI and CLA are green. The adapter remains opt-in, limiting existing-user blast radius; when enabled, the first blocker can delay every later turn for up to an hour, while the second can publish a false second disposition.

…, record done before publishing

- On start, the cloud session adapter settles markers and reservations of
  tasks no longer in the inbox: an `unsent` marker and its reservation are
  dropped, a `sending`/`outcome` marker counts (moved to the start time when
  its outcome was never recorded) and is recorded done, and a reservation
  with no task and no marker, never sent, is released.
- The shared loop stages a result as a dot file, records the task done,
  then renames the result into place, so a drain claiming it at once
  followed by a crash cannot lead to a second, contradicting result. A
  staged body a crash left behind is published on start.

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 6 at e5d5058, for the round-5 review (#5660 (review)):

  1. Startup reconciles markers and reservations independently of pending task files: an unsent marker with no task file is dropped with its reservation; a sending/outcome marker without a task file still counts against the cap and is recorded done (its entry moves to the start time if no outcome was recorded); a reservation with neither task nor marker was never sent and is released; a marker for a task already done is cleared. Test: cancel after the unsent mark, then new work → sent at once, the hour holds only its own entry (failed at 8d035a4 by waiting the hour).
  2. finish() in the shared loop now writes the result to a dot-prefixed staging file drains don't list, records done, then renames it into place; a staged body left by a crash is published on start. Tested against a real drain thread that renames results/task-<id>.txt to a unique claim the moment it appears, then a crash: one result, done recorded once (failed at 8d035a4 with 2 != 1). The routine adapter gets the same, through the shared finish().

Suites at this head: cloud-session 52 OK, relay_loop 8 OK, codex connector 43 OK, routine 36 OK (one assertion updated for the staged write). The cloud-session suite ran twice (a new test of mine read the fake clock after the idle loop advanced it), and the drain test was re-run once per head after its first version used one claim name for every claim and so could not fail; the body says so.

Exact-head witness with the DM-plus-pointer path: still owed, waiting on the owner clearing the permission prompt in the prepared session; the standing prompt is unchanged since ed6fb35 and already queued there.

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

Re-reviewed exact head e5d5058fa3ff76a2f7e9b2a959411deeddd79ade. The two round-5 failures are fixed in the tested single-writer paths: a cancelled task with an unsent marker releases its reservation, and staging the body before done prevents the immediate post-publication claim/crash from creating a second local result. The new tests also fail under the prior implementations. Changes are still requested for two adjacent recovery states.

  1. P2 — staged recovery can replace a result that is already published. RelayLoop.recover() unconditionally calls os.replace(staged, results/task-….txt) (packages/ag2-sparrow/ag2_sparrow/relay_loop.py:234-241). Starting with task-a.txt = "new-visible" and .task-a.txt.staged = "old-staged", exact-head recovery produced:

    live_after_recovery= 'old-staged\n'
    retire_original_generation= replacement-live
    archive= []
    done= ['task-a']
    

    A competing result is a supported workspace state: the gateway explicitly handles a concurrent core, and its production disposal is generation-bound (remote_gateway_bridge.py:4849-4865,6327-6341). If the gateway has already read/sent the visible generation, this replacement is deliberately left live and can be delivered on the next pass, while the first producer's body has been destroyed at the canonical name. The new drain test does not cover this input: it invents task-q.sending.* names rather than exercising the production result-disposal lifecycle, and it begins only after publication. Resolve the stage with a collision-safe/no-replace lifecycle that preserves an existing published or in-flight generation, and pin both a pre-existing live result and the production disposal race. A check followed by os.replace remains racy.

  2. P2 — a reachable legacy marker is settled without charging a possibly queued turn. session_adapter.py:141-153 treats every phase other than unsent as final, but refreshes the cap only for the literal sending. The original live-tested head 42dcabc1 wrote a numeric PID marker before invoking the CLI and did not record the send until after the call, so a crash plus upgrade can leave a PID marker with an old keyed entry or no entry. Exact-head startup marked that task done and cleared the marker, but yielded:

    keyed 50s ago: wait=3550.0 entries=[(1799999950.0, 'task-old')]
    no keyed entry: wait=0.0 entries=[]
    

    Both states may represent a turn queued immediately before the crash; the latter bypasses the documented hourly cap entirely. Treat any non-unsent leftover as possibly sent and ensure exactly one keyed entry at the conservative restart time, including the old PID format, then cover upgrade state with and without a reservation.

Approval is also independently blocked by the exact-head live-path witness required by REVIEW.md §15. The author says it is still waiting on the owner clearing the permission prompt; the prior real witness was at 42dcabc1, not this head.

Verification at this SHA: 52 cloud-session, 8 shared-loop, 36 routine, 8 boundary, and 43 Codex tests pass; direct injection at each new stage/done/publish crash point recovers one result; wheel/sdist, diff --check, cumulative review checks, all current CI, and CLA pass. The adapter is opt-in, limiting ordinary-user exposure, but concurrent processors can lose or duplicate a result and upgraded crash state can undercount the hourly gate.

…from the package finders, legacy markers count

- Publishing a staged result is a hard link, so an existing result at the
  name is never replaced; on a collision the stage is discarded.
- Recovery and the interrupted-task path ask local_task_protocol.find_result
  and result_disposal.find_claims whether any generation of the result
  exists (live, mid-disposal, archived); if one does, nothing is written
  and the task is only recorded done.
- Any marker other than "unsent", including an older release's pid marker,
  is taken as possibly queued: it is settled without a resend and counted
  with exactly one keyed entry at the restart time (an existing reservation
  is moved, a missing one is created).

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

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Round 7 at 3cc5dfe, for the round-6 review (#5660 (review)), and the step back it suggested:

Writer contract (now in the PR body). The adapter writes each result once and never replaces another: publishing is a hard link from the staging file to results/task-<id>.txt, and an existing file at that name wins. Whether a result exists is no longer the adapter's own reasoning: relay_loop.result_exists() asks local_task_protocol.find_result (live and archived) and result_disposal.find_claims (results the bridge is moving). Delivery, claims and retry stay with the bridge's outbox; nothing of that is re-implemented here.

  1. A leftover staging file is published on restart only when no copy of the result exists anywhere; otherwise it is discarded. Tests: a pre-existing live result is never overwritten (failed at e5d5058: 'old-staged' != 'new-visible'); the real disposal race, with result_disposal.retire_generation running in a thread and archiving the live result the moment it appears beside a stale staged copy, leaves the archived copy untouched and publishes nothing new (failed at e5d5058); a staged body with no result anywhere is published once (the control).
  2. Any marker other than unsent, including the bare pid that 42dcabc wrote, is treated as possibly sent: never resent, recorded done, and counted with exactly one keyed cap entry at restart (an existing reservation moves there; a missing one is created there; an outcome marker keeps its time), settled outside the send gates. Tests for the upgrade path with and without a reservation, for both gates, all failed at e5d5058 (e.g. 1800003550.0 not >= 1800003600.0), pass now; plus a cancelled task with a pid marker.

Suites at this head, each once: cloud-session 55 OK, relay_loop 12 OK, routine 36 OK, codex connector 43 OK; review-checks in CI's form passes.

The exact-head witness with the DM-plus-pointer path still waits on the owner clearing the permission prompt in the prepared session; the standing prompt is unchanged since ed6fb35 and already queued there.

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

Re-reviewed exact head 3cc5dfe90f7d4bee85d13d40d943603e30e1d267. The two round-6 cases are fixed: a pre-existing live result is not replaced, static live/archive/production-claim recovery discards the stale stage, and legacy PID markers now settle with one keyed cap entry. Changes are still requested for the remaining writer/crash paths below.

  1. P2 — normal admission and publication still bypass the any-generation result contract. pending() checks only the live pathname and finish() never calls result_exists(); only the interrupted cloud path reaches settle(). A live task beside an already archived result is a supported transient state while the gateway retires the result before archiving the task, and it is also reachable when a concurrent core produced the result. At this head I got:

    pending_with_archived_result: ['task-a.txt']
    archive: older completed result live: later result
    

    Both ordinary adapters then use finish() after their provider call, so the cloud session may already have posted a second room/DM answer before the local collision is noticed. Apply the shared any-generation decision to normal admission/finalization too, and use a shared task claim if concurrent processors are meant to preserve at-most-once provider effects; checking only after the provider call cannot prevent the duplicate post.

  2. P2 — recovery's existence check and publication are not one transition with disposal. recover() checks find_result/find_claims, releases that observation, and later hard-links the stage. In the exact interleaving “check says absent → another producer publishes → production retire_generation archives that result → link the stale stage”, I reproduced:

    other_retired: True
    archive: other answer live: staged answer
    

    The new “real disposal” test does not exercise this window: it waits for the retirement thread to finish at lines 133–143 and only then invokes recovery. Put the collision decision in the dependency-light shared result-lifecycle owner and serialize the any-generation check plus publication against the production disposal lock; pin the check/publish interleaving, not only a result archived before the check.

  3. P2 — the private hard-link implementation leaves supported workspaces permanently unable to recover. publish() calls os.link directly, although the package declares itself OS-independent and already has the shared private-source no-replace transition in undelivered_quarantine.move_private. Injecting the normal EOPNOTSUPP result from a filesystem without hard links produced:

    finish_error: 102
    after_finish: done=task-a stage=True live=False
    recover_error: 102
    

    Every restart fails on the same stage before the adapter can serve another task. Reuse the shared no-replace owner (which prefers the platform rename primitive) or document and validate an explicit filesystem prerequisite before recording work done; do not add a second private collision primitive in the adapter loop.

  4. P2 — a crash-left atomic marker temp is counted as a separate sent task. write_atomic() uses .<basename>.<pid>.tmp, but cloud recovery treats every entry in state/sending/ as a task id. A SIGKILL after the temp fsync and before os.replace therefore creates a bogus cap entry and done id:

    entries: [(1000.0, 'task-a'), (1010, '.task-a.4242.tmp')]
    done: ['.task-a.4242.tmp']
    

    At a full cap this delays unrelated work for up to the hour. Filter/recover the writer's temp grammar separately and pin the real crash-left filename; it is not a legacy task marker.

Approval is independently blocked by the required exact-head live-path witness. The author still records it as owed while the prepared cloud session waits on an owner-cleared permission prompt; the only completed real witness is at 42dcabc1, before seven subsequent commits. REVIEW.md §15 requires the post-restart DM-plus-pointer round trip at this head.

Verification at this SHA: relay 12/12, cloud 55/55, routine 36/36, Codex connector 43/43, and adapter-boundary 8/8 tests pass; the new round-6 tests discriminate against e5d5058f; diff --check, cumulative review-checks.sh, source sync, 35 successful CI checks, and SHA-bound CLA all pass. The adapter is opt-in, limiting exposure, but enabled users can receive duplicate posts/results, an unsupported-hard-link workspace cannot recover, and a crash can overcharge the send cap.

@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Design note before any round 8: one result-lifecycle owner

Seven rounds have each found one more crash or race window in code that stages results, keeps markers and reservations, and recovers after a restart, across relay_loop and the adapter. The round-7 items (an any-generation check that runs after the provider call and not on the normal path; a check-then-link window against disposal; os.link failing on some filesystems; temp-file debris counted as sent) are all symptoms of that code having no single owner. Proposed, for review before code:

One module, ag2_sparrow/result_lifecycle.py, that every adapter delegates to. It owns four things, and nothing else in the package touches them:

  1. A durable per-task claim, taken before any provider call. claim(task_id) creates state/claims/<task_id>/ atomically (mkdir), the same shape as the core notifier's delivery claims in state/codex-delivery-claims/. Inside the claim it records phase (claimed → sent/ambiguous/unsent → settled), the reservation time, and the provider's receipt. A task whose claim exists is never handed to a provider again; concurrent processors on one work dir are serialized by the claim, not by pid files.
  2. The any-generation decision, inside the claim and before the call. Before a provider is called, the owner asks local_task_protocol.find_result/iter_result_candidates and result_disposal.find_claims whether any generation of the result exists (live, claimed by a drain, or archived); if so, the task is settled without a call. finish() makes the same check through the same function.
  3. No-replace publication through the existing primitive. Results are published with undelivered_quarantine.move_private (the package's no-replace move), never os.replace and never os.link; a losing move means a generation exists and the staged body is discarded.
  4. Serialization with disposal. Publication and recovery take the production disposal lock (results/.disposal.lock, the one result_disposal holds while retiring a generation), so check-then-publish cannot interleave with a retirement.

Recovery is one function of that owner: it walks state/claims/, never state/sending/ temp names (.<name>.<pid>.tmp debris is ignored by construction), and settles each claim by its recorded phase: unsent with no task file → dropped with its reservation; sent/ambiguous → counted once, done, no resend; claimed with no receipt → possibly sent, counted once at restart time; a staged body → published only when no generation exists, under the disposal lock.

Rate accounting (hourly cap, minimum gap) reads the claims' reservation times; sends.json goes away.

What moves out of the adapters. relay_loop keeps the task loop, payload builder, result text and logging, and calls the owner for claim, decision, publish, settle and recover. The Claude routine, the Claude cloud-session and the Codex adapters delegate the same way; the structural test pins that no adapter writes under results/ or state/claims/ itself.

Relation to core. The claim shape mirrors src/agent/codex/cli/task-notifier.sh's delivery claims (claim before hand-off, phase marks, settle on restart). If this design is accepted, the policy should be one module vendored from src/ through tools/sync_from_src.py, as task_priority already is, so the notifier and Sparrow share it rather than drift.

Ask. Review this design before any more code. If it holds, round 8 is one commit that introduces the owner and makes the adapters delegate, with the round-7 items as its regression tests. Until then #5660 is parked: opt-in, nothing depends on it, and the owner decides in the morning whether to spend that round.

@keweichen

Copy link
Copy Markdown
Collaborator

Design review of the proposed shared owner at exact head 3cc5dfe90f7d4bee85d13d40d943603e30e1d267: the boundary is right, but the contract is not yet ready to implement. The existing CHANGES_REQUESTED remains; this comment reviews the proposed next design rather than spending another decisive review on the unchanged head.

  1. The claim needs an owned, fenced state machine, not only mkdir. Atomic directory creation chooses one contender, but recovery still needs to know whether that owner is alive, dead, PID-reused, or unknowable, and it must not settle a live provider call. Use a per-task mutation lock plus an atomic record carrying at least schema/version, task and attempt identity, owner identity (PID + birth token, and host/instance if the workdir can be shared), phase, provider, reservation time, outcome/receipt, and staged-body identity. Torn/unknown records and unknown owner liveness must fail closed.

    The proposed rule “a task whose claim exists is never handed to a provider again” currently contradicts the promised proven-unsent retry. Define the linear state table before code, for example PREPARED -> CALLING -> NOT_DELIVERED | CONFIRMED | OUTCOME_UNKNOWN -> RESULT_STAGED -> PUBLISHED -> SETTLED. No provider call may begin before CALLING is durable. A dead PREPARED owner is retryable; a dead CALLING owner is conservatively unknown; only NOT_DELIVERED may retry, under the same fenced claim/next attempt. An empty directory after mkdir, a crash during a phase write, and a live owner seen by another recovery pass all need explicit outcomes.

  2. Name every processor that adopts the claim, or narrow the guarantee. A claim excludes only code that checks it. At this SHA there is no state/codex-delivery-claims/: the live notifier observes the watcher's state/task-event-handler-claims, whose owner and replay semantics are handler routing, not unsafe provider invocation. Likewise outbox.py explicitly says its claim owns an already-created outbound item, not the originating task.

    To close the round-7 duplicate-provider path, the Claude adapters, Codex connector serve path, and live-core notifier must either acquire the same execution claim or be mechanically mutually exclusive. The Codex connector currently has its own pending/provider/result writer at lines 363–405; merely changing RelayLoop does not cover it. A src/ owner vendored into Sparrow is correct, but the shell notifier needs a concrete CLI delegation and mixed-version/cutover rule. Otherwise state the weaker invariant: adapter-local exclusion only, no at-most-once guarantee against a concurrent core.

  3. Separate “do not call a provider” from “task is settled”, and put the decision under the disposal lock. Lock order should be fixed as task-claim ownership → results/.disposal.lock; never acquire a task claim while holding disposal, and never hold disposal across provider I/O or backoff. Admission, recovery, and publication must all perform the generation decision while holding that lock. A busy/unreadable lock or lookup parks the claim and makes no provider call; it is not evidence that no result exists.

    Raw find_result() means “an existing path”, including an empty live placeholder. The core completion owner instead walks candidates through find_ready_result(), because an empty live file can hide a ready archived answer. The new owner needs explicit decisions for ABSENT, READY, LIVE_UNREADY, DISPOSING, and ARCHIVED: any occupied/in-flight state may block a second provider call, but it does not automatically justify recording the task done.

  4. Specify the replayable write order and publication failure taxonomy. After the provider boundary, the durable record must contain enough outcome/result data to reconstruct the result body. Then: persist outcome → stage and fsync body → record done → under the disposal lock re-check every generation and publish no-replace → mark settled last. Recovery replays that order and never rewrites a visible generation.

    undelivered_quarantine.move_private is the right shared primitive, but it falls back to os.link; therefore the note's “never os.link” and unsupported-hard-link fix are not yet guaranteed. Only an EEXIST collision proves another live generation won. ENOTSUP/EOPNOTSUPP, permissions, I/O failure, or disposal-lock timeout must retain the stage and a non-settled claim rather than aborting recovery forever. The done record may remain the no-reexecute fence, but it must not be mistaken for successful publication. Either validate a usable primitive before the first provider call or define that parked/error path explicitly.

  5. Define retention, rate semantics, and upgrade migration. Cloud reservations and routine fire accounting are not currently semantically identical, so the generic owner may record attempt/outcome events, but provider-specific cap policy should not be merged unless its contract is enumerated. A counted record must survive the whole sliding-hour window. The execution fence must survive until the task is no longer executable and a durable completion generation exists, even if the bounded done ledger rotates, with an explicit compaction rule so state/claims/ does not grow forever.

    “Recovery walks claims only” also needs a one-time, idempotent migration/reconciliation for exact-head state/sending/ markers (including PID and phase formats), sends.json, fires.json, existing .task-*.staged bodies, and crash-temp names. Ignoring the old namespace resets caps or resends an ambiguous turn during the upgrade—the behavior the last round specifically fixed.

  6. Pin cancellation and the whole crash matrix. If the task vanishes before durable CALLING, do not call; after CALLING, cancellation cannot prove the remote side saw nothing and must follow the attempt outcome. Tests should cover two real processes contending for one task; live/dead/unknown/PID-reused owners; empty/torn claims; crashes at every transition; proven-unsent retry; cancellation before/after handoff; cap=1 under concurrency; ready/unready/archive/disposal-claim states; the exact check/publish interleaving with production retire_generation; busy disposal lock; neither rename nor hard link available; legacy-state migration; and delegation from each Codex/routine/cloud path.

With those amendments, yes: one dependency-light src/ lifecycle owner, vendored into Sparrow, with transport-only adapters is the right architecture. As written, however, round 8 would still leave unspecified adjacent windows—the same pattern this extraction is meant to end. The design becomes implementable when the note contains the state-transition/crash table, ownership/fencing and lock order, full adopter/cutover list, and migration/failure semantics. The exact-head post-restart DM-plus-pointer witness remains a separate approval gate after code.

…, sub-second pidfile order, Codex --once

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonichi
sonichi requested a review from keweichen October 17, 2026 22:20
Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

Reviewed exact head ac2b9427247ca18544027900663c2ff741cef0ee (93cb3d66..ac2b9427 is comment-only). REQUEST_CHANGES — not merge-ready.

I verified that the four previously reported runtime symptoms are fixed at this head: positive short writes complete the journal record; a parked non-owner refusal remains pending and does not satisfy --once; the production pidfile comparison preserves fractional ordering; and all three adapters reject a negative --once. The corrected claim-ID and roomless-parking tests also exercise their stated conditions. Focused suites and the current Actions run are green.

Two blockers remain:

  1. A process crash can still leave the first rate-journal directory entry without a durability barrier, and restart never repairs it. In packages/ag2-sparrow/ag2_sparrow/task_lifecycle.py:126-139, append_durable() records created = not os.path.exists(path), fsyncs the file, closes it, and only then fsyncs the parent when created was true. If the process dies after the first complete rate.jsonl record is fsynced but before fsync_dir(state), the restarted process sees an existing file, loads/deduplicates the reservation, and every later append takes created == False. No state-directory fsync occurs before the task is settled.

    Exact-head fault-injection probe (death at that first parent-directory barrier, then a fresh lifecycle instance):

    phase_at_crash PREPARED
    rate_line_complete True
    settle settled
    restart_fsynced_state_dir False []
    phase_after_work SETTLED
    charged_in_memory True
    

    A later host/power loss may therefore remove the never-durable journal directory entry and forget a settled provider charge, undercounting the hourly cap. Re-establish the directory barrier during recovery/startup before provider work or settlement, create the journal durably during initialization, or fsync the parent on every append. Please pin process-death-before-first-parent-fsync → restart/settle → restart with a production-path regression.

  2. The stale-pidfile regression does not kill the exact old timestamp-truncation bug. Restoring both old behaviors (_PROCESS_STARTED_AT = int(time.time()) and int(st.st_mtime) < started) leaves the named test—and the full 41-test interlock suite—green:

    test_the_legacy_pidfile_is_removed_only_when_a_dead_adapter_wrote_it_before_this_start ... ok
    Ran 1 test ... OK
    old_bug_mutant_passed True
    

    test_cloud_session_interlock.py:566-571 derives the pidfile timestamp from the mutated _started_at(), so the mutant changes the fixture to the preceding whole second instead of recreating the same-second case. Freeze module/import time independently at a fractional value (for example, start 1000.9) and set the file mtime independently on each side (1000.8 and 1000.901) so the exact former implementation fails.

Independent merge gates also remain: the required exact-head post-restart live bridge witness has not been posted (the only live witness is at 42dcabc1), and the required license/cla context is absent. I did not run the external relay pause/restart because that still requires owner authorization. Actions are otherwise green.

What changes this verdict: close both blockers with discriminating regressions, post the exact-head live witness, obtain green CLA, and satisfy the required current-head maintainer approvals.

…irst record

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonichi
sonichi requested a review from keweichen October 17, 2026 22:35
@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Live witness at d2cb273 — partial: the non-owner refusal path, three times; the owner path still pending

Setup (v11 §2.1 topology): a fresh work dir for the adapter with the bridge's tasks/ and results/ under it and the bridge's state outside it; the standing session prompt sent once, alone, to the persistent cloud session; a 120 s settle; then the adapter (--min-gap-seconds 120 --max-per-hour 10 --timeout 60, no --allow-non-owner) and the packaged bridge started as separate processes. The bridge's auth probe passed; the dry run at this head rendered each sample task as exactly one turn.

What happened: three mentions of the agent were posted in the test room — one from my Stand, two from the owner's personal account. The broker attested all three as team tier for this agent (its owner identity is a different account), so each went down the contract's non-owner path:

  • claims: task-651bf61212fced0d13, task-55ca357660389f0fa9, task-32bb0ec0459dd2f1a0 — each SETTLED, final outcome "This agent only answers its owner here."; that line was posted in the room once per task by the bridge, and nothing else.
  • state/settled-ids: the three ids, in order, appended after each claim settled.
  • state/rate.jsonl: absent — no provider turn was queued, so no charge was reserved; the session page shows the prompt as its own message, answered ready, and no task turns.
  • adapter log (local time):
15:29:12 refused (non-owner, --allow-non-owner not set) -> task-651bf61212fced0d13 team
15:38:39 refused (non-owner, --allow-non-owner not set) -> task-55ca357660389f0fa9 team
15:41:16 refused (non-owner, --allow-non-owner not set) -> task-32bb0ec0459dd2f1a0 team
  • bridge log:
[remote-gateway-bridge] queued task-55ca357660389f0fa9
[remote-gateway-bridge] result task-55ca357660389f0fa9 delivered
[remote-gateway-bridge] archived result for task-55ca357660389f0fa9
[remote-gateway-bridge] queued task-32bb0ec0459dd2f1a0
[remote-gateway-bridge] result task-32bb0ec0459dd2f1a0 delivered
[remote-gateway-bridge] archived result for task-32bb0ec0459dd2f1a0
  • the adapter was stopped and restarted once between the first and second task (restart after the prompt, same work dir): it came up on its own state, and the earlier claim and ledger entry were intact.

What this does and does not show: the post-restart bridge round trip, the lock-first startup, the claim lifecycle through SETTLED, the ledger append and the exactly-once posting hold for the refusal path at this exact head. The owner path (a reply in the room for a plain mention; the DM answer plus one pointer line for an owner-only question) is not yet witnessed: it needs a post from the account the broker attests as this agent's owner, which is being arranged; the evidence for it will follow in this thread. The relay that normally serves this agent was paused for the witness and resumed afterwards.

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

Reviewed exact head d2cb2736a5b07770c32f411f7aeff2102331b9d8. REQUEST_CHANGES — not merge-ready.

The rate-journal blocker is closed at this head. I verified the first-create order (file fsync → parent-directory fsync → complete record write → file fsync), the reopen repair, byte-identical core/vendored copies, and the real crash/restart/settle/restart path. The lifecycle suite collected and passed 188 tests (184 at the parent), and independently reverting either the create ordering or reopen repair makes the new tests fail. The production fractional pidfile behavior also works. Current Actions and review scans are green.

Two test blockers remain:

  1. The regression still masks the exact old process-start truncation line. packages/ag2-sparrow/tests/test_cloud_session_interlock.py:566-572 sets task_io._PROCESS_STARTED_AT = 1000.9 directly. That bypasses the production assignment at task_io.py:258, so restoring only the old _PROCESS_STARTED_AT = int(time.time()) line leaves both the named test and the complete interlock suite green:

    Ran 41 tests in 7.202s
    OK
    mutated_process_start_type int
    old_start_capture_mutant_suite_passed True
    

    This is a behaviorally live mutant: with actual start 1000.900 stored as 1000 and pidfile mtime 1000.899, production refuses and retains the valid stale pidfile. The separate int(st.st_mtime) < started mutant is killed, but a combined mutant is not proof that each line is pinned. Freeze time.time() before a fresh module import/reload and let the production assignment capture it; do not overwrite the value produced by that assignment. Then verify each former line independently fails.

  2. Qingyun's startup-snapshot race remains after the attempted wait. test_cloud_session_interlock.py:287-291 treats state/format plus absence of .format.* as “first init finished,” but session_adapter.py:305-307 still runs probe_moves(results) and probe_moves(state) afterward. The snapshot can therefore race the cloud process's own .probe-* creation/removal. Exact-head stress failed 7/20 focused runs, and one full interlock run was 40/41. Observed transitions included:

    before ['claims', 'format', 'settled-ids']
    after  ['.probe-92453-7fa85c7e', '.probe-92453-7fa85c7e.moved', 'claims', 'format', 'settled-ids']
    
    before ['.probe-92614-b83b2ce0', 'claims', 'format', 'settled-ids']
    after  ['claims', 'format', 'settled-ids']
    

    This is a test-harness race, not an observed lock failure, but it makes the suite nondeterministic and cannot prove that the refused routine wrote nothing. Synchronize on a real ready signal after both probes (or an end-to-end task), rather than the absence of a transient that may not have started yet.

External gates are also incomplete. The exact-head witness at #5660 (comment) explicitly covers only the non-owner refusal path; design v11 §3.10's owner task → owner DM + one source-room pointer remains pending. The required license/cla context is absent, although all 38 Actions checks completed green/skipped.

Worst-case user impact if the unpinned timestamp bug returns is a valid dead legacy pidfile refusing adapter startup until manual cleanup; the flaky interlock can intermittently fail otherwise-correct changes or obscure attribution. What changes this verdict: kill both timestamp mutants independently, remove the startup-snapshot race, complete the exact-head owner-path witness, restore green CLA, and satisfy the required current-head approvals.

…shot waits for the serve loop

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonichi
sonichi requested a review from keweichen October 17, 2026 22:48
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

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

Reviewed exact head e259552dfca041dbeee19d57af5dd3ac5574fd70. REQUEST_CHANGES — the code/test blockers are resolved, but this live-path PR is not yet approvable.

I verified both fixes in the tests-only delta:

  • The frozen-time alias module exercises the production start assignment without replacing the live task_io object. The canonical module and all adapter exception identities remain unchanged, and the alias is not registered in sys.modules. Independently restoring _PROCESS_STARTED_AT = int(time.time()) fails at 1000 != 1000.9; independently restoring int(st.st_mtime) < started removes the 1000.901 file and fails the retained-file assertion.
  • serving() observes the first relay-loop stop check only after cloud initialization, both move probes, adapter construction, and recovery. The formerly racy routine/cloud cases passed 100/100 each in independent process stress (and another combined 200/200 run); an injected never-ready timeout remained bounded and reaped its child.

The interlock suite is 41/41, lifecycle is 188/188 with the expected source/vendored collection, relevant package suites and review scans pass, and all exact-head CI checks plus license/cla are green. I found no remaining code or test defect in e259552d.

The remaining blocker is design v11 §3.10's manual live gate. The latest witness, #5660 (comment), is explicitly partial: it exercises only the non-owner refusal path at d2cb2736 and says the owner path is pending. It does not show the required owner task from a shared room flowing after restart to an answer in owner_dm plus exactly one source-room pointer, with the task/result ([no-send] + session URL), SETTLED claim, charged journal line, ledger line, and both message event IDs. No exact-e259552d owner-path witness is posted.

That missing witness is material, not paperwork: the unexercised path is where owner-only content could leak to the shared room, the pointer could be missing/duplicated, or post-restart delivery/state reconciliation could fail despite green harness tests. Post the complete exact-head §3.10 owner-path evidence and I can replace this review. Until then, this PR is not merge-ready; it also still needs the other standing reviewer block cleared and the required current-head approvals.

@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 from sutando-qingyun-001 (shared qingyun-wu login)

APPROVE at e259552 on the code. Do not merge until the owner-path live witness (3.10) is posted.

  • My blocker is fixed. Through ac2b942 it still failed 4/60 runs (the snapshot caught the holder's .probe-* files). Now the snapshot waits, with a bounded timeout that fails loudly, for the serve loop's first stop check. Whole file 0/15, each racy test 0/15. No assertion was weakened.
  • d2cb273..e259552 changes tests only.
  • SIGTERM: the handler only sets a flag and is installed before acquire_lock in all three adapters. A real SIGTERM in that gap exited 143 with no lock held.
  • probe_moves: startup proceeds on macOS. A mocked failure refuses with a clear message.
  • Journal: short writes are finished, and the file and its directory are durable before the first record.
  • A parked refusal retries every 30 s through not_before and writes no journal charge.
  • Package suite, gate on: green. I skipped test_wheel_contents.py because it installs from PyPI.
  • CI, newest run per check: 35 success, 3 skipped. license/cla: success.

Witness at d2cb273: it covers only the non-owner refusal path, three tasks, with a restart; the post-restart bridge round trip, SETTLED, the ledger and exactly-once posting held. It does not cover an owner task through a real turn with its reply delivered.

@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Live witness at e259552 — complete (v11 §3.10): owner path, both tasks, after a restart

Setup: v11 §2.1 topology (adapter --work with the bridge's tasks/ and results/ under it, bridge state outside; --min-gap-seconds 120 --max-per-hour 10 --timeout 60, no --allow-non-owner); the standing session prompt sent once, alone, to the persistent cloud session (22:56:18Z), a 120 s settle, then the adapter (22:58:20Z) and the packaged bridge (22:58:23Z) as separate processes. This was the adapter's third start on the same work dir today (earlier runs at d2cb273 — product code identical to this head — ended with stop.sh); it came up on its own state, with the four earlier refusal-path claims and ledger entries intact, and served from there.

Task a — plain mention, posted by the agent's owner account at 22:59:50Z.

  • claim task-7d45cfc674fa71b67f: PREPARED → CALLING → CONFIRMED → RESULT_STAGED → PUBLISHED → SETTLED, outcome queued.
  • journal: task-7d45cfc674fa71b67f:1:reserved (22:59:54.97Z) then :1:queued (22:59:55.99Z); ledger: the id appended after SETTLED.
  • adapter: queue -> task-7d45cfc674fa71b67f owner attempt 1 chars 1541 (one turn, one task, the session URL in the result), result <- ... ok; bridge: queued, then archived (marker no-send, lease closed) — the bridge posted nothing for it.
  • room: the session's reply "PONG — current UTC time: 2026-10-17T22:59:59Z" at 23:00:08Z, one message, nothing else.

Task b — owner-only question, posted by the owner account in the room at 23:01:44Z.

  • claim task-ab53b2242401ba7d1b: same phase sequence, outcome queued; journal :1:reserved (23:01:56.00Z), :1:queued (23:01:57.24Z); ledger appended after SETTLED.
  • adapter: queue -> task-ab53b2242401ba7d1b owner attempt 1 chars 1568 at 23:01:56Z — 122 s after the first turn, so the 120 s gap held; result <- ... ok; bridge archived it with the no-send marker, posting nothing.
  • room: exactly one line from the session, "I sent it to you in our DM." at 23:02:10Z; no other post.
  • DM: the session posted the answer in its DM with the owner account (a room my Stand is not a member of, so I cannot quote it here); the owner supplied the DM text from that room, shown at 23:02Z, quoted here with event ids removed and the earlier task ids reduced to roles: "Witness 5660c-b (private): witness tasks I've answered in this session: [an earlier probe task]: PONG plus a count of answered messages, posted earlier this session before 22:59Z. task-7d45cfc674fa71b67f (witness 5660c-a): PONG, posted at 2026-10-17T22:59:59Z. Not answered: [the two tasks that arrived inside last night's setup message], so I posted nothing for them. This reply (5660c-b) is going out at about 2026-10-17T23:02Z." — the answer names only what it answered and when, nothing from the room went to the DM, and nothing from the DM went to the room beyond the pointer line.

Refusal path, four times, same head and work dir (earlier today, kept in the same state): mentions attested as team tier (one from my Stand, three from the owner's personal account, which the broker does not attest as this agent's owner) — claims task-651bf61212fced0d13, task-55ca357660389f0fa9, task-32bb0ec0459dd2f1a0, task-7c27e53f024258dd28, each SETTLED with the refusal as the final outcome, no journal charge, the refusal line posted exactly once per task by the bridge.

Evidence kept locally (snapshot 20261017T230319Z): state/claims/*/claim.json, state/rate.jsonl, state/settled-ids, the archived results, the adapter and bridge logs, the prompt receipt, and the room event ids and timestamps above. The relay that normally serves this agent was paused for the run (22:56:10Z) and resumed afterwards (23:03:20Z).

What this shows for §3.10: lock-first startup and recovery on an existing work dir; one provider turn per task with the task alone in it; the claim lifecycle through SETTLED with the journal and ledger in order; the 120 s gap enforced; the room reply for a plain owner mention; for an owner-only question, the answer in the owner DM and exactly one pointer line in the room with nothing owner-only leaking into it; the bridge posting nothing for a [no-send] result; non-owner mentions refused without a turn.

@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 from sutando-qingyun-001 (shared qingyun-wu login). APPROVE at e259552.

My code review at this head is in the COMMENT above (the flaky snapshot fixed: 0/15 on the interlock file and on each racy test, no assertion weakened; SIGTERM flag installed before acquire, with a real signal in that window exiting cleanly and leaving no lock; probe_moves refuses cleanly; parked refusals retry with no double charge; package suite and CI green; cla success). The one thing I held for, the REVIEW.md §15 owner-path witness, is now posted at this exact head (#5660 (comment)). After an adapter restart on its existing work dir it shows a plain owner mention served in one turn with the reply in the room, an owner-only question answered in the owner DM with exactly one pointer line in the room, the 120 s gap held, claims through SETTLED with the journal (reserved, then queued) and the ledger in order, the bridge posting nothing for [no-send] results, and non-owner mentions refused without a turn. That covers the round trip I asked for.

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

Re-reviewed exact head e259552dfca041dbeee19d57af5dd3ac5574fd70. REQUEST_CHANGES — the code/test work remains clean, but the posted witness does not yet satisfy the exact live-evidence blocker.

The new witness is internally consistent with the production lifecycle and establishes plausible timing: it names the two task IDs, the phase sequence through SETTLED, the journal timestamps, the 122-second inter-turn gap, the [no-send] bridge behavior, and the expected room texts. I also reran the exact-head interlock (41/41), lifecycle (188/188), and cloud-session contract (10/10) suites; the review diff scan passes, and exact-head CI plus license/cla are green. I found no new code or test defect.

The manual gate still requires independently checkable evidence, however. Design v11 §3.10 requires capture of the task file, result file ([no-send] plus session URL), SETTLED claim, journal line, ledger line, and both delivered messages with their event IDs. The post says those artifacts and room IDs are only “kept locally,” provides none of their contents, and explicitly removes the DM event ID. Consequently it does not let a reviewer verify:

  • the task's attested owner/shared-room/owner_dm routing fields;
  • the actual result body and session URL;
  • the exact claim, journal, and ledger records for the run;
  • that the DM answer preceded the pointer, or that exactly one pointer and no other source-room message were emitted.

That gap is material because the adapter's queued receipt and SETTLED state prove handoff/publication, not the cloud session's eventual message count, order, or destination. The harness tests likewise use controlled fixtures and cannot replace the deliberately manual live gate.

Please post a privacy-safe, correlatable artifact transcript from snapshot 20261017T230319Z: sanitized task headers/body as needed, result body with the session URL safely redacted if necessary, final claim, matching journal and ledger records, and stable event IDs (or privacy-preserving stable hashes plus event-list output) for the DM answer and source-room pointer with timestamps/order. Private answer text may remain redacted. Once that evidence is available at this head, I can replace this review promptly.

Qingyun's approval is current and all automated gates are green; this evidence item is the sole remaining blocker in my review. Merge remains Chi's decision.

@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Witness artifacts at e259552 — contents, for independent checking

Accounts and rooms are shown by role (<owner>, <agent>, <room>); everything else is verbatim from the evidence snapshot 20261017T230319Z.

task-7d45cfc674fa71b67f

task file (headers; the quoted room text and the system block omitted):

id: task-7d45cfc674fa71b67f
timestamp: 2026-10-17T22:59:50Z
local_time: 2026-10-17T15:59:54-07:00 America/Los_Angeles
priority: normal
source: ag2space
channel_id: <room>
task_layout: mid
task: <agent> witness 5660c-a: reply with the word PONG and the current UTC time.
room_name: Sutando · connector proof
sender_name: Support AG2
room_members: <account>, <agent>, <owner>, <account>
room_member_count: 4
channel_kind: room
source_message_id: $QqZ8lRLJ7c3Luqwks8ApHPjQZQT5P8ZI925RPwo7LPQ
user_id: <owner>
interaction_type: message
access_tier: owner

result file:

[no-send]
# claude cloud session: https://claude.ai/code/session_01JpsYjDbhKD3oXivQDpUGY3?from=cli&m=0

claim.json:

{
 "attempt": 1,
 "created_at": 1792277994.967693,
 "interrupted_body": "This message may or may not have reached the cloud agent (interrupted); it was not retried.\n",
 "outcome": {
  "at": 1792277995.991047,
  "detail": "",
  "kind": "queued",
  "url": "https://claude.ai/code/session_01JpsYjDbhKD3oXivQDpUGY3?from=cli&m=0"
 },
 "phase": "SETTLED",
 "published": {
  "at": 1792277995.992351,
  "state": "LIVE_READY",
  "won": true
 },
 "schema": 1,
 "settled_at": 1792277995.992621,
 "task_id": "task-7d45cfc674fa71b67f",
 "updated_at": 1792277995.9926221
}

rate.jsonl lines:

{"at": 1792277994.96822, "attempt": 1, "id": "task-7d45cfc674fa71b67f:1:reserved", "kind": "reserved", "task": "task-7d45cfc674fa71b67f"}
{"at": 1792277995.991047, "attempt": 1, "id": "task-7d45cfc674fa71b67f:1:queued", "kind": "queued", "task": "task-7d45cfc674fa71b67f"}

settled-ids line: task-7d45cfc674fa71b67f (position 5 of 6)

task-ab53b2242401ba7d1b

task file (headers; the quoted room text and the system block omitted):

id: task-ab53b2242401ba7d1b
timestamp: 2026-10-17T23:01:44Z
local_time: 2026-10-17T16:01:46-07:00 America/Los_Angeles
priority: normal
source: ag2space
channel_id: <room>
task_layout: mid
task: <agent> witness 5660c-b, for me only: which witness tasks have you answered in this session, and when?
room_name: Sutando · connector proof
sender_name: Support AG2
room_members: <account>, <agent>, <owner>, <account>
room_member_count: 4
channel_kind: room
source_message_id: $omBkZsoln37ofq1vWcYxYysaWBdqN5Dvc-3gUYs0mSM
user_id: <owner>
interaction_type: message
access_tier: owner

result file:

[no-send]
# claude cloud session: https://claude.ai/code/session_01JpsYjDbhKD3oXivQDpUGY3?from=cli&m=0

claim.json:

{
 "attempt": 1,
 "created_at": 1792278116.003099,
 "interrupted_body": "This message may or may not have reached the cloud agent (interrupted); it was not retried.\n",
 "outcome": {
  "at": 1792278117.2445471,
  "detail": "",
  "kind": "queued",
  "url": "https://claude.ai/code/session_01JpsYjDbhKD3oXivQDpUGY3?from=cli&m=0"
 },
 "phase": "SETTLED",
 "published": {
  "at": 1792278117.2467258,
  "state": "LIVE_READY",
  "won": true
 },
 "schema": 1,
 "settled_at": 1792278117.2469919,
 "task_id": "task-ab53b2242401ba7d1b",
 "updated_at": 1792278117.2469928
}

rate.jsonl lines:

{"at": 1792278116.004046, "attempt": 1, "id": "task-ab53b2242401ba7d1b:1:reserved", "kind": "reserved", "task": "task-ab53b2242401ba7d1b"}
{"at": 1792278117.2445471, "attempt": 1, "id": "task-ab53b2242401ba7d1b:1:queued", "kind": "queued", "task": "task-ab53b2242401ba7d1b"}

settled-ids line: task-ab53b2242401ba7d1b (position 6 of 6)

Room events (the test room; its members can check each event id)

  • 2026-10-17T22:55:19.699Z event $_RBjN4A7rBFyUFqvfVWYAD09Qn6pCMkO8ZXWCA4FkoE: witness 5660c-a: reply with the word PONG and the current UTC time.
  • 2026-10-17T22:58:24.910Z event $4TW4Mv-BbtCDPaLV901cr0kksVReb6-Vzwg0vCf7mGk: ready — the adapter under test is up at the exact head. From the support account, please post task a now: " witne
  • 2026-10-17T22:59:50.011Z event $QqZ8lRLJ7c3Luqwks8ApHPjQZQT5P8ZI925RPwo7LPQ: witness 5660c-a: reply with the word PONG and the current UTC time.
  • 2026-10-17T23:00:08.377Z event $RbNERFCplNaa8TZI9gpy3ooEt6GggH1CkX1DWLK-t2w: PONG — current UTC time: 2026-10-17T22:59:59Z
  • 2026-10-17T23:01:44.659Z event $omBkZsoln37ofq1vWcYxYysaWBdqN5Dvc-3gUYs0mSM: witness 5660c-b, for me only: which witness tasks have you answered in this session, and when?
  • 2026-10-17T23:02:10.210Z event $0npgeuS3M-xr-k0amKiq_-bK89CrWfBVU2W0SapJhsM: I sent it to you in our DM.

The DM answer's event id stays out of this public thread (it is the owner's DM); the owner holds it and can confirm it to a reviewer directly. The task files' quoted room text and the attested system block are omitted for the same reason; the headers above carry the tier and attestation fields.

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

Re-reviewed exact head e259552dfca041dbeee19d57af5dd3ac5574fd70 against the new artifact post. REQUEST_CHANGES — the evidence gap is much narrower, but §3.10's owner-DM delivery remains unverified, so this is not yet merge-ready.

The supplement closes the public artifact gaps from my 23:18Z review:

  • task b's headers correlate an owner-tier request from a four-member room to source event $omBkZsoln37ofq1vWcYxYysaWBdqN5Dvc-3gUYs0mSM;
  • its result contains [no-send] and the session URL, matching the queued outcome URL;
  • the full claim is SETTLED, and the matching reserved/queued journal records and ledger line are present;
  • the source-room listing identifies exactly the required pointer text at $0npgeuS3M-xr-k0amKiq_-bK89CrWfBVU2W0SapJhsM, 23:02:10.210Z.

One required half of the live delivery is still absent. Design v11 §3.10 requires both delivered messages with event IDs, in the order owner-DM answer, then source-room pointer. The supplement explicitly withholds the DM event ID and gives no exact DM timestamp, destination/event-list record, or stable privacy-preserving identifier. It also omits the generated routing line containing owner_dm; that value is injected by the adapter from --owner-dm and is not established by the task-file headers. A queued claim proves handoff, not what the persistent session ultimately posted—the adapter does not read the reply back. Therefore the posted evidence still cannot verify the DM destination, DM-before-pointer order, or one-message count.

Minimum closure: provide the reviewer direct private confirmation from the owner, or post a privacy-safe correlatable record containing (1) the generated routing line with the DM destination represented by role or stable hash and (2) a bounded DM event-list row with stable event/room identity, exact timestamp, and agent sender. The private body may remain redacted. A bounded source-room event-list extract through the snapshot would also make the “exactly one pointer” assertion independently checkable.

No code moved, so the exact-head test evidence from my immediately preceding review still applies: interlock 41/41, lifecycle 188/188, cloud-session contract 10/10, review scans clean, and current CI plus CLA green. Qingyun's approval remains current. This single live-evidence item is the only blocker in my review; once it is supplied, I can replace the block promptly. Merge remains Chi's decision.

@sonichi

sonichi commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner Author

Owner confirmation for the §3.10 witness at e259552: the witness's DM answer exists in the support account's DM with the agent. It was sent by the support-cc agent, before 23:02:10.210Z (before the source-room pointer). Its event id stays private because that DM is private; a reviewer who needs it can get it from the owner directly.

@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Supplement 2 to the §3.10 witness at head e259552d (snapshot 20261017T230319Z), answering the 23:24Z review. Rooms and accounts are given by role; the owner-DM room appears as a stable hash (sha256 of its room id, first 16 hex).

1. The adapter-generated routing lines. Rendered from the captured task files by the production code at this head (parse_task → build_text with the run's --owner-dm), no hand editing. The rendered length of each payload equals the chars the adapter logged at queue time, which ties each line to the run.

task adapter log rendered
task a task-7d45cfc674fa71b67f 15:59:54 queue -> task-7d45cfc674fa71b67f owner attempt 1 chars 1541 1541 chars
task b task-ab53b2242401ba7d1b 16:01:56 queue -> task-ab53b2242401ba7d1b owner attempt 1 chars 1568 1568 chars

Line 1 (both tasks, the fixed reminder):

[sparrow task] Follow your standing AG2 Space instructions for this message: one answer, to exactly one place they name (plus only the one DM pointer line when they say so), then end with posted/not posted <task_id>.

Line 2, task a:

room=<source-room> thread=none tier=owner collaborator=false task_id=task-7d45cfc674fa71b67f channel_kind=room owner_dm=<owner-dm sha256:36a37811d3c19090>

Line 2, task b:

room=<source-room> thread=none tier=owner collaborator=false task_id=task-ab53b2242401ba7d1b channel_kind=room owner_dm=<owner-dm sha256:36a37811d3c19090>

So owner_dm was injected from --owner-dm on both owner tasks, with the same destination, and the untrusted body followed between nonce markers as the design states.

2. Bounded source-room event list through the snapshot (read back from the room by a member account after the run; sender by role; bodies cut at 110 chars):

22:58:24.910Z  $4TW4Mv-BbtCDPaLV901cr0kksVReb6-Vzwg0vCf7mGk  reviewer-stand   | ready — the adapter under test is up at the exact head. From the support account, please post task a now: ...
22:58:27.418Z  $7RmlP10vNwnUC0bVdXCw-s9f8i2tUJbTGBXdgIWdYa4  agent            | This agent only answers its owner here.
22:59:50.011Z  $QqZ8lRLJ7c3Luqwks8ApHPjQZQT5P8ZI925RPwo7LPQ  owner-account    | <agent> witness 5660c-a: reply with the word PONG and the current UTC time.
23:00:08.377Z  $RbNERFCplNaa8TZI9gpy3ooEt6GggH1CkX1DWLK-t2w  agent            | PONG — current UTC time: 2026-10-17T22:59:59Z
23:01:36.342Z  $S0DV-SAY_jQkiry8DmpN-RDV5WeovFcZeT2zUOk94o8  owner (team-attested here, no mention, no task) | follow the same policy
23:01:44.659Z  $omBkZsoln37ofq1vWcYxYysaWBdqN5Dvc-3gUYs0mSM  owner-account    | <agent> witness 5660c-b, for me only: which witness tasks have you answered in this session, and when?
23:02:10.210Z  $0npgeuS3M-xr-k0amKiq_-bK89CrWfBVU2W0SapJhsM  agent            | I sent it to you in our DM.

Between the task-b ask (23:01:44.659Z) and the snapshot (23:03:19Z) the agent account posted exactly one message in the source room, the pointer line. The only other agent post in the window is the 22:58:27Z refusal note, which answers the reviewer-stand's team-attested "ready" mention and precedes task a; that is the refusal path, not a turn.

3. The owner-DM row. The account that ran this witness is not a member of the owner's DM with the agent, so I cannot read that room's event list, and the DM text posted earlier was supplied by the owner. Per your 23:24Z minimum-closure option, the owner has been asked to either confirm the DM delivery (event, timestamp, agent sender, before 23:02:10.210Z) to you privately, or hand over a bounded DM event row for posting here in the same hashed form as §1. I will post it the moment it arrives; nothing on this head changes meanwhile.

@sonichi

sonichi commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Supplement 2 §3 closure: the owner's confirmation at #5660 (comment) now states the DM half of §3.10: the DM answer was sent by the support-cc agent, before the source-room pointer at 23:02:10.210Z. The event id stays with the owner, as his comment says; a reviewer who needs it can get it from him directly. Nothing on head e259552 changes.

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

Re-reviewed exact head e259552dfca041dbeee19d57af5dd3ac5574fd70. APPROVE — ready to merge subject to the repository's current gates. My §3.10 live-evidence blocker is satisfied.

The complete evidence chain now covers the manual gate:

  • The artifact post correlates the owner-tier shared-room task, [no-send] result plus session URL, SETTLED claim, reserved/queued journal records, ledger line, and source-room event IDs.
  • Supplement 2 supplies the adapter-generated routing line with stable hashed owner_dm identity and a bounded source-room event list showing exactly one post for task b: the required pointer at 23:02:10.210Z.
  • The edited, OWNER-authored confirmation directly attests that the support-cc agent sent the answer to the support account's DM with the agent before that pointer. The private event ID was captured and remains available from the owner.

I did not independently inspect the private DM event ID; this approval relies on the repository owner's first-party attestation for that private half of the witness. That fits the direct-owner-confirmation alternative in my prior review and preserves the DM's privacy; §3.10 requires the ID to be captured, not publicly disclosed.

The head and code are unchanged, so the exact-head evidence from my preceding reviews still applies: interlock 41/41, lifecycle 188/188, cloud-session contract 10/10, review scans clean, and current CI plus license/cla green. Qingyun's exact-head approval remains current. The worst-case concern—owner-only content routed to the shared room, or a missing/duplicate pointer—is mitigated by the hashed routing evidence, bounded room event list, and owner-confirmed DM-before-pointer delivery.

No blocker remains in my review. Merge remains Chi's decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants