Repository navigation
feat(sparrow): Codex adapter — task files become turns on one Codex app-server thread - #5611
Conversation
…pp-server thread A stdlib connector that owns a `codex app-server` child over stdio, keeps one durable thread, submits each tasks/task-*.txt as a turn inside a random-tagged untrusted-data wrapper, and writes results/task-<id>.txt atomically. `--push` hands the same wrapped text to `codex queue` for a thread owned by a running TUI. A stub app-server is the test double; CI runs the new test file with the other out-of-tree sparrow tests. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.pyLines 100-122 100 self.next_id = 1
101 threading.Thread(target=self._reader, daemon=True).start()
102
103 def _reader(self):
! 104 for line in self.p.stdout:
! 105 line = line.strip()
! 106 if not line:
! 107 continue
! 108 try:
! 109 m = json.loads(line)
! 110 except ValueError:
! 111 continue
! 112 if "id" in m and "method" not in m:
! 113 q = self.pending.pop(m["id"], None)
! 114 if q is not None:
! 115 q.put(m)
116 else:
! 117 self.events.put(m)
! 118 self.events.put(None)
119
120 def _send(self, obj):
121 with self.lock:
122 self.p.stdin.write(json.dumps(obj) + "\n")Lines 147-156 147
148 def close(self):
149 try:
150 self.p.stdin.close()
! 151 except OSError:
! 152 pass
153 try:
154 self.p.wait(timeout=5)
155 except subprocess.TimeoutExpired:
156 self.p.kill()Lines 391-399 391 deadline = time.time() + timeout
392 while True:
393 remaining = deadline - time.time()
394 if remaining <= 0:
! 395 return None
396 try:
397 m = self.rpc.events.get(timeout=remaining)
398 except queue.Empty:
399 return NoneLines 422-430 422 interrupted; if it still does not complete within the grace, the server is
423 replaced, so the next task never shares the thread with a live turn."""
424 params = {"threadId": self.thread_id, "input": [{"type": "text", "text": prompt}]}
425 if model:
! 426 params["model"] = model
427 res = self.rpc.request("turn/start", params)
428 turn_id = res["turn"]["id"]
429 done = self.wait_turn(turn_id, self.args.turn_timeout)
430 if done is not None:Lines 461-469 461 if status == "completed" and not (text or "").strip() and attempt == 1:
462 log("empty reply; retrying once")
463 continue
464 return status, text, err
! 465 return status, text, err
466
467 def result_body(self, status, text, err):
468 if status == "completed":
469 return (text or "").strip() or NO_REPLYLines 498-507 498 log("idle-exit")
499 return
500 try:
501 m = self.rpc.events.get(timeout=self.args.poll)
! 502 if m is not None and "id" in m:
! 503 self.handle_server_request(m)
504 except queue.Empty:
505 pass
506 continue
507 name = names[0]Lines 618-627 618 c.drop_scratch()
619 log("closed pid", c.rpc.p.pid, "rc", c.rpc.p.returncode)
620 try:
621 os.unlink(lock)
! 622 except OSError:
! 623 pass
624 return 0
625
626
627 if __name__ == "__main__":packages/ag2-sparrow/ag2_sparrow/adapters/codex/stub_app_server.pyLines 45-53 45
46 for line in sys.stdin:
47 line = line.strip()
48 if not line:
! 49 continue
50 m = json.loads(line)
51 meth, rid, p = m.get("method"), m.get("id"), m.get("params") or {}
52 if meth is None and rid in PENDING:
53 tid, turn_id, item_id, reply = PENDING.pop(rid)Lines 120-126 120 STALLED.pop(p["turnId"])
121 send({"jsonrpc": "2.0", "method": "turn/completed",
122 "params": {"threadId": tid, "turn": {"id": p["turnId"], "status": "interrupted", "items": [],
123 "error": {"message": "interrupted"}}}})
! 124 elif rid is not None:
! 125 send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "stub: unknown method %s" % meth}})packages/ag2-sparrow/tests/test_codex_connector.pyLines 34-42 34 cwd, where the gate's `coverage combine` picks it up."""
35 argv = REAL_SERVER_ARGV(server)
36 if server == "stub" and os.environ.get("SUTANDO_TEST_SUBPROCESS_COVERAGE") == "1":
37 return [sys.executable, "-m", "coverage", "run", "--rcfile=%s" % (REPO_ROOT / ".coveragerc")] + argv[1:]
! 38 return argv
39
40
41 cc.server_argv = covered_server_argvLines 425-433 425 raise RuntimeError("stuck")
426 return {"turn": {"id": "t1"}, "thread": {"id": "th1"}}
427
428 def notify(self, method, params=None):
! 429 pass
430
431
432 class EdgePathTest(unittest.TestCase):
433 def test_list_models_and_loaded_threads_print_json(self): |
…-safe results Headers come from the package parser (hatch's attested_headers over local_task_protocol), so a gateway task_layout: mid file takes the writer's access_tier from below the body and its guardrail lines stay outside the untrusted wrapper. A turn past --turn-timeout gets turn/interrupt and a bounded grace; if it still runs, the app-server is replaced and the thread resumed before the next task. thread/resume carries the thread settings and a stale id falls back to thread/start; state/thread-id is read back; the result mtime is taken from the temp fd before the rename; state/done keeps finished ids so a drained result is not re-run; request logging names methods only. The order test now gives lexically later names the earlier mtimes. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ack, no restart after a late completion State the one-line mid-body assumption the gateway's writer guarantees; a thread/resume that times out falls back to thread/start like a refused one; a turn that completes between the deadline and the interrupt is read as done instead of restarting the app-server. Both new paths are driven by the stub. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…age gate Coverage discovery takes tests/**/*.test.py only, so the package suite never ran under coverage and diff-cover saw 0% for both adapter files. Add the tests/ runpy wrapper the other package suites use, trace the stub child under SUTANDO_TEST_SUBPROCESS_COVERAGE like the runtime-cli suites, and cover the remaining paths: list-models/list-loaded, server exit mid-turn, the other server requests and error notifications, idle exit, the proxy no-restart branch, done-list bounds, Rpc kill on EOF-ignoring child, env fallback. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@qingyun-wu review requested: /packages/ code owner. CI green at fd2ddf2 (26 tests; coverage 97% on the adapter). Evidence, live measurements and limits are in the body. Sibling #5612 adds a line at the same ci.yml spot; whichever merges second needs a base merge. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-owner review for /packages/ from sutando-qingyun-001 (shared qingyun-wu login), at fd2ddf24.
Verdict: REQUEST_CHANGES. There are two blockers, both on paths a guest can reach.
Already sound: the connector picks the result file from the inbox file name (:365), so the model can't redirect a reply. An absent or unknown tier resolves to guest. Header policy goes through the shared task_fields.attested_headers rather than a copy. Mutants are caught: tier forced to owner, interrupt removed, wrong result id, restart skipped, approval flipped to accept. CI and CLA are green.
Blocker 1: non-owner tiers get a label, not limits
wrap_untrusted(codex_connector.py:127-139) putsaccess_tier=into a metadata line, then tells the model to "Act on it as the user's request". There is no non-owner wording like Hatch'sNON_OWNER_NOTE(hatch/ag2-sparrow-hatch.sh:34).thread_params(:197-201) starts every tier withapprovalPolicy: "never"and the samesandbox. The default isread-only, butdanger-full-accessis one flag away, and nothing varies by tier.read-onlystops writes, not reads. A guest can ask for workspace or memory content, and the reply is posted to the room.--push(:386-393) queues guest text into the owner's own TUI session.- Deferring enforcement to #5610 isn't acceptable for a path guests can reach. The minimum is one of:
- refuse non-owner tiers until #5610 lands; or
- add the NON_OWNER_NOTE wording to the wrapper, and run non-owner turns read-only on their own thread.
--pushshould refuse non-owner tiers in either case.
Blocker 2: one shared thread leaks across rooms and tiers
serve()runs every task, from every room and every tier, on one durable thread (:247,:298).- The PR's own live run shows the leak. Task 2 answers
41, recalled from task 1 (README l.65-71). - So a guest in room B can ask what room A's owner said.
- Fix: keep one thread per
channel_id, or per (channel, tier), or use an ephemeral thread per task for non-owner tiers.
Non-blocking
- No envelope verification. Sparrow never stamps, so
invalidandunsignedtasks are submitted. That's acceptable, but say so in the README. - No coverage on the sandbox choice. The mutant forcing
"danger-full-access"passes all 26 tests. Pin it, especially once tiers get their own sandbox. - Server exit mid-turn. There's no restart, and the next
turn/starthits a closed pipe and endsserve. - Empty completed turn. It writes
"\n"and is marked done, so the room gets nothing. - Timeout bodies. These post
[turn timeout] {error json}to the room. - Single-instance lock. There isn't one.
- Merge order. The
ci.ymlline sits at the same spot as #5612's.
Not verified
- How far Codex's
read-onlysandbox lets reads reach outsidecwd. - What happens live when the server dies.
… threads keyed per channel A non-owner task (team/guest/other/ambient, from the attested headers) now runs on its own ephemeral thread, read-only whatever --sandbox says, with the Hatch adapter's NON_OWNER_NOTE in the prompt (read from ag2-sparrow-hatch.sh at startup, pinned fallback kept byte-identical by a test), and --push refuses it. Owner tasks keep a durable thread per channel_id under state/threads/, so rooms never share memory. Also: a server that dies mid-turn is restarted (owner: thread resumed and the turn retried once; non-owner: plain failure body); an empty completed turn is retried once, then "[no reply]"; timeout and failure bodies are plain text; one connector per work dir via a liveness-checked pidfile; README states that envelopes are never verified and why, and the merge order vs #5612. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ine NON_OWNER_NOTE match Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-owner re-review from sutando-qingyun-001 (shared qingyun-wu login) at 27af0a20. That is 27aab92 plus a docs-only commit, and it replaces my review at fd2ddf2.
Verdict: REQUEST_CHANGES. Both of my earlier blockers are fixed, but the new per-channel thread keying has two small blockers of its own.
Fixed
- Non-owner sandbox: non-owner turns always run
read-only, whatever--sandboxsays (codex_connector.py:271-273). - Non-owner threads: non-owner turns get a fresh ephemeral thread per task and per retry, so nothing carries over, not even within one room.
- Note:
NON_OWNER_NOTEis on every non-owner turn. The fallback matches Hatch, and an empty match now falls back. --push: it refuses non-owner tiers.- Retry: a dead-server retry happens at most twice, and the result is written once.
- Failure bodies: they're plain text.
- Mutants: each of these is caught:
- non-owner turns using
args.sandbox; - one shared owner thread;
- non-owner turns on a durable thread;
- the note dropped;
--pushallowing non-owner tiers;- the pidfile check off (the suite hangs, and CI's timeout catches it).
- non-owner turns using
Blockers
--thread-idmerges channels.open_owner_threaddoeswanted = wanted or self.args.thread_id, so every owner channel without a saved file resumes the same thread.- In a probe, rooms A and B both got
T-shared, and each channel's file then stores it permanently. That undoes per-channel isolation for owner context across rooms. - Fix: bind
--thread-idto one named channel, or drop it.
channel_idhandling in the thread-file name.channel_keykeeps..and., so the path becomesstate/threads/...open()then raises an uncaughtIsADirectoryError(reproduced).- The task is never marked done, so every restart crashes the same way.
- Keys also collide in two ways (both reproduced):
- two ids that differ only after character 120;
!a:b.organd_a_b.org, which map to the same key.
- Fix: name the file by a hash of
channel_id, e.g. sha256 hex.
Non-blocking
- Reads aren't confined. A non-owner turn's cwd is
--work, which holds every room'stasks/andresults/. A read-only sandbox still allows reads, and the README says how far they reach "is not measured". Use an empty temp dir as the non-owner cwd, and measure the read reach. - Pidfile race. Read-then-write isn't atomic, so two simultaneous starts can both win.
- Owner retry repeats the turn. After a server death, side effects under a writable sandbox can run twice.
- Missing
channel_id. It maps to a shareddefaultowner thread. It's owner-only and documented.
CI: only license/cla had finished when I posted.
…d to one channel The thread file is named by the sha256 of channel_id (the readable id is kept inside the file), so `..` cannot escape state/threads and ids that differ only after the old 120-char cut, or only in sanitized characters, get distinct files. `--thread-id` takes CHANNEL_ID=THREAD and seeds exactly that channel; a second channel starts its own thread. Non-owner turns run in an empty temp dir removed after the turn; the pidfile is created O_EXCL with a stale-unlink retry; README notes that an owner retry after a server death can repeat side effects under a writable sandbox. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@qingyun-wu both blockers are addressed, now at 4bc90a6 (second-round items below); CI is running on that head. Please re-review. B1, non-owner tiers: the wrapper for team/other/guest/ambient now carries Hatch's B2, shared thread: owner threads are keyed per Non-blocking, done: README states envelope verification is absent (Sparrow never stamps) and why; sandbox default pinned; server death → restart + resume + one retry (owner) / plain-text failure (non-owner), 40 tests (one foreground run, output in the body); adapter coverage 97% locally; review-checks PASS. Second round (27af0a2 → 4bc90a6): |
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-owner re-review from sutando-qingyun-001 (shared qingyun-wu login) at 4bc90a6f. This replaces my REQUEST_CHANGES at 27af0a2.
Verdict: APPROVE. Merge once CI is green; 13 checks were still pending when I posted.
- Thread files are keyed by digest.
channel_key(codex_connector.py:76-79) issha256(channel_id), ordefaultwhen there's none. A..or.id can no longer escape the directory, and the 120-char and punctuation collisions are gone. The readable id is kept inside the file, capped at 80 chars. --thread-idis bound to one channel.bound_thread_id(:82-87) takesCHANNEL_ID=THREADand returns the thread only for that exact channel, so other channels start their own thread. Splitting on the first=is safe, because Matrix room ids contain no=.- Earlier fixes still hold. Non-owner turns are always
read-onlyon an ephemeral thread per task, carry theNON_OWNER_NOTE, and are refused by--push. - Tests:
packages/ag2-sparrow/testspasses locally, 335 tests. - Non-blocking: non-owner turns still run with cwd
--work, which holds every room'stasks/andresults/. An empty temp dir as the non-owner cwd, plus a measurement of how far reads reach, would close that.
…apter # Conflicts: # .github/workflows/ci.yml
|
@cla-assistant check |
What changed, and why
A new opt-in adapter,
packages/ag2-sparrow/ag2_sparrow/adapters/codex/, that pushes task files into a running OpenAI Codex session as new turns — the Codex counterpart of a Claude Code mod's$.prompt.submit. Research showed three workable shapes and one that is not (hooks); the adapter implements the two that keep memory across tasks:--server stdio): the connector spawnscodex app-server --listen stdio://, handshakes (initialize/initialized), starts or resumes one durable thread, submits eachtasks/task-*.txtasturn/startinside a random-tagged untrusted-data wrapper, takes the turn's finalagentMessage, and writesresults/task-<id>.txtvia temp-file +os.replace. One task in flight, mtime order,task:parsed as the terminal header. Approval requests that still arrive are declined and recorded.--push <thread> <task-file>: the same wrapped text handed tocodex queue --thread, for a thread owned by a running interactive TUI (Codex ≥0.157 runs the TUI on the shared daemon).stub_app_server.pyis the test double (same JSON-RPC shapes, no model). The new test is added to the CI out-of-tree sparrow list, next totest_hatch_adapter.py.Placed under
ag2_sparrow/adapters/codex/(notpackages/ag2-sparrow/adapters/) because that is where the package's existing adapter lives and[tool.setuptools.packages.find]discovers it.Read first
codex_connector.py(parse_task,wrap_untrusted,write_atomic,Connector.run_turn,push), thentests/test_codex_connector.py, then the README's tables.What this proves
Memory across tasks on one thread, a completion signal per task, atomic result publication, and a push path with no keystrokes. The README records what remains unproven (resume on a real durable thread, a hand-written client on the shared daemon, real approval round trips).
Feature/documentation consistency
N/A for
docs/: this adds an optional package adapter with its own README; no user-visible Sutando behavior changes.src/is untouched. Adopting it for the Codex core is the follow-up #5610 (not closed here).Before/after evidence
Parent commit (
origin/main), the test path does not exist:HEAD (
39b335b), the single evidence run (Python 3.14.6, stub only — no network, no realcodex):Honesty note (first commit): the first run of this file had two failures, both test-side — a regex that also counted the tag's prose mention, and a
run=subprocess.rundefault bound at definition time that let the mock miss (so that test had called the realcodex queue). Both fixed in this commit; the output above is the rerun.bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD)→review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean).Live measurements (dev Mac, Codex CLI 0.160.1 logged in via ChatGPT, read-only sandbox, approvals
never, ephemeral thread,gpt-6.1-sol— no mini model is offered to the account, prompts were one line each)PONG 4141DECLINEDOK 4* the three files pre-existed at start-up, so "detect" includes the handshake and queueing. Task 2 answering
41is the memory-across-tasks proof; task 3 is the wrapper holding.--pushpath, own TUI in an own tmux server:codex queuereturned rc 0 in 0.13 s; the TUI rendered• QUEUED OK10.42 s later (model ~2 s, the rest is the TUI's drain cadence). Precondition hit first: a zero-turn thread answersno rollout found for thread id.Live path?
No: nothing under
src/, no bridge, no delivery loop, no startup path changes. The measurements above are the adapter's own round trips, not a Sutando restart.Stacked?
N/A. Follow-up (not closed by this PR): #5610 — replace the Codex core's
tmux send-keyswithcodex queue --thread.Hardcoded-path check
Scanner PASS (above). The connector takes every path as an argument (
--work,--cwd); the only literal is thePATHfallback/usr/bin:/binfor the child's environment.Worst-case disruption
None for existing installs: the adapter is opt-in, imported by nothing, and the CI change only adds one test invocation.
Review round 1 (REQUEST_CHANGES at
c05d00e) — fixed in39b335badapters/hatch/task_fields.attested_headersoverlocal_task_protocol(strict task-last, trusted last-wins only undertask_layout: mid), tier canonicalised the way hatch does (absent/unknown →guest). For a mid file the writer's one-line message goes inside the wrapper; the bridge-written lines belowaccess_tier(tier guardrail, secret notice) go after the closing marker as operator text. Tests: a fixture built from the gateway's shape with the realag2space_tier_lines, a forgedaccess_tier: ownerin the body that does not win, and a delegation pin that patchesattested_headersand asserts the call. The tier in the wrapper is annotated, not enforced; Codex core: replace tmux send-keys withcodex queue --thread(lost-Enter reproduced) #5610 must carry enforcement.--turn-timeoutgetsturn/interrupt {threadId, turnId}(schema-exact), then--interrupt-grace; if it still does not complete, the app-server process is replaced and the thread resumed before the next task starts. Stub gained[[stall]](completes on interrupt) and[[stall-hard]](ignores it); both paths tested, including that the second task lands on the same thread id after the restart.names.sort()would now fail it.thread/resumecarries cwd/approvalPolicy/sandbox/model and a stale id logs and falls back tothread/start(tested);state/thread-idis read back on start (tested); result mtime comes from the temp fd beforeos.replace(tested against a claim-rename); a task counts as done if its result exists or its id is instate/done(bounded), so a drained result is not re-run (tested); request logging is method names only; README: unconditional decline,--threadby name marked unexercised.First run of the revised file had one failure, a real one:
state/donelost its order by round-tripping through a set. Fixed; the output above is the rerun.Coverage gate (REQUEST at
0cda940: diff-cover saw 0% for both adapter files) — fixed infd2ddf2Cause, read from
.github/workflows/ci.yml+scripts/discover-python-tests.sh: the coverage legs run onlytests/**/*.test.pyandskills/**/*.test.py;packages/ag2-sparrow/tests/test_*.pyis run by the out-of-tree step without coverage. The package suites that are measured have atests/ag2-sparrow-<name>.test.pyrunpy wrapper (e.g.test_human_action.py); this PR addstests/ag2-sparrow-codex-connector.test.pythe same way. The connector already ran in-process; the stub child is now traced too underSUTANDO_TEST_SUBPROCESS_COVERAGE=1with thecoverage run --rcfileprefix the runtime-cli suites use (rcparallel=True, the leg'scoverage combinemerges the fragment). Remaining paths got tests (list-models/list-loaded, server exit mid-turn, other server requests + error notifications, idle exit, proxy no-restart branch, done-list bounds, Rpc kill, env fallback,--workrequired).Single foreground run (Python 3.14.6; the usage text is the expected stderr of the
--work-required test):Local coverage, same collection as the gate (from the repo root):
Code-owner review (REQUEST_CHANGES at
fd2ddf2) — fixed in27aab92B1 non-owner tiers. (a) The wrapper now carries the Hatch adapter's
NON_OWNER_NOTEfor every non-owner tier, read fromag2-sparrow-hatch.shat startup with a pinned fallback;test_pinned_note_is_byte_identical_to_the_hatch_scriptkeeps the two identical (same read-then-pin shape as #5612).collaboratorrides in the metadata. Owner tasks keep "Act on it as the user's request"; non-owner bodies are data with the note. (b) Non-owner turns run on their own ephemeral thread per task,approvalPolicy: never, sandboxread-onlyregardless of--sandbox; owner turns keep the durable thread. The mutant test runs a guest with--sandbox danger-full-accessand asserts the stub sawsandbox=read-only ephemeral=True(and the owner task in the same run sawdanger-full-access ephemeral=False). (c)--pushrefuses every non-owner tier with exit 2 and a message,subprocess.runnever called.B2 thread leak. Owner threads are keyed per
channel_idunderstate/threads/<key>; tests: two owner channels get distinct ids and a channel keeps its id; an owner + two guests + a team task yield four distinct ids and only the owner thread is saved. README's live-run narrative now says the41recall was one owner channel and that a guest has no such memory.Non-blocking, done: README states Sparrow never stamps envelopes, so
invalid/unsignedare submitted and the tier is the only gate (hence the isolation); sandbox default pinned (read-onlyis the parser default and the only non-owner value); server exit mid-turn → restart + resume + one retry (owner) or a plain failure body (non-owner), andservegoes on (tested both); an empty completed turn retries once then writes[no reply] …(owner retry succeeds on the same thread; a guest's fresh thread is empty again →[no reply]); timeout/failure bodies are plain text with any partial reply below (no JSON; asserted); single-instance lockstate/connector.pidwithos.kill(pid, 0)liveness (tested live pid → exit 3, dead pid → taken over, garbage → taken over, released on exit); README merge-order note vs #5612.Single foreground run (Python 3.14.6;
Error: no rolloutandQueued message…are the mockedcodex queueoutputs):Local coverage, same collection as the gate:
First run of the revised suite had one real failure: the metrics row of a task whose server was restarted recorded
thread: null; fixed (the row records the thread the task ran on). The outputs above are the rerun.Code-owner re-review (REQUEST_CHANGES at
27af0a2) — fixed inbce3491--thread-idis nowCHANNEL_ID=THREADand seeds exactly that channel (bound_thread_id); the test runs rooms A and B with--thread-id '!a:x=bound-0001'and asserts A resumed it and B did not.channel_keyis the sha256 hex ofchannel_id(defaultwhen absent); the readable id is written inside the file, never in its name. Tests:..,., a 200-char pair differing after char 120, and!a:b.orgvs_a_b.orgall map to distinct 64-hex files; an owner task from channel..completes and saves under its digest.cwd= a fresh empty temp dir removed after the turn (tested: the stub saw it, it is gone afterwards, the owner's cwd is--work); pidfile viaO_CREAT|O_EXCLwith stale-check → unlink → one retry (tested live/dead/garbage/double-failure); README notes the owner retry-after-death side-effect caveat.🤖 Generated with Claude Code