diff --git a/docs/design/agent-workflows/projects/harness-session-resume/experiments/protocol.md b/docs/design/agent-workflows/projects/harness-session-resume/experiments/protocol.md new file mode 100644 index 0000000000..ee3f3d267f --- /dev/null +++ b/docs/design/agent-workflows/projects/harness-session-resume/experiments/protocol.md @@ -0,0 +1,119 @@ +# Experiment protocol: harness session resume across a process kill + +This file records the exact, reproducible steps run on the dev box on 2026-07-09 to answer two +questions about coding-agent harness behavior: + +- Q1 (write timing): is the harness session file on disk current mid-turn, at the moment the + harness is blocked on a permission gate, with the pending `tool_use` already persisted? Or is it + flushed only at turn end? +- Q2 (dangling-call handling): when a session file ends in an unanswered `tool_use` and the harness + loads it, does it (A) answer/re-ask the SAME `tool_use` id, (B) settle it as interrupted and let + the model re-issue a NEW `tool_use` with full structured context, or (C) fail or drop back to the + last user message? + +Everything ran in scratch directories. No git or `but` operations. The running dev-stack containers, +the subscription sidecar, and other agents' Claude sessions were left untouched. + +## Environment + +- Box: hetznerdev, `/home/mahmoud/code/agenta`. +- `claude` CLI: version 2.1.205, authenticated with a Claude Max subscription (OAuth), model + `claude-fable-5`. +- `claude-agent-acp` (the ACP adapter the runner ships on): version 0.23.1, at + `services/runner/node_modules/@zed-industries/claude-agent-acp/`. It wraps + `@anthropic-ai/claude-agent-sdk`, which spawns the same `claude` CLI under the hood. +- Pi: `@earendil-works/pi-coding-agent` 0.79.4 bundled under + `services/runner/node_modules/.pnpm/`, plus `pi-acp` 0.0.29. Pi is not installed on the host + (`which pi` fails), so Pi was analyzed from source (static-only). See report for the reasoning. +- Scratch root: `/home/mahmoud/.claude/jobs/1d53079e/tmp/exp-claude/`. + +## The logging proxy + +A ~110-line single-file Python proxy (`proxy.py`, run with `uv run`) listens on +`127.0.0.1:8791`, forwards every request to `https://api.anthropic.com`, and appends a JSONL log +line per request: method, path, model, message count, and a compact per-message signature +(`role[blocktype+blocktype...]` with `tool_use`/`tool_result` ids). The harness is pointed at it with +`ANTHROPIC_BASE_URL=http://127.0.0.1:8791`. This makes the invariant check a literal diff of the +call sequence. The Claude Max subscription traffic did route through the proxy, so it captured real +calls. + +Note: the proxy logs request bodies only (not responses), so a model's `tool_use` output appears in +the NEXT request (as history), never in the request that produced it. + +## A required workaround: child-session env scrubbing + +The box runs Claude agents. Their environment exports `CLAUDECODE=1`, +`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION_ID`, and related vars. A `claude` (or ACP adapter) +launched with those inherited auto-enters bypass-permissions mode, which never hangs on a gate. Every +launch below first unsets: `CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_SESSION_ID +CLAUDE_CODE_CHILD_SESSION CLAUDE_CODE_BRIDGE_SESSION_ID CLAUDE_CODE_EXECPATH CLAUDE_CODE_SSE_PORT`. +This scrubbing is essential to reproduce a real permission gate. + +## Experiment C, Level 1: Claude Code CLI + +Driven under tmux (session `expclaude`) so a real TTY permission prompt can hang. Two full cycles +were run (`proj/` and `proj2/`) to confirm reproducibility. + +1. Start the proxy in the background. +2. `tmux new-session -d -s expclaude -x 200 -y 50`. In the pane: unset the child-session vars, `cd` + into the scratch project dir, `export ANTHROPIC_BASE_URL=http://127.0.0.1:8791`, then launch + `claude --permission-mode default`. Accept the folder-trust prompt with `1`. +3. Send the prompt: `Run this exact bash command with the Bash tool, nothing else: echo TOKEN-8f3a + > proof.txt` (cycle 2 used `TOKEN-9c2b`). +4. Poll `tmux capture-pane` until the Bash permission prompt appears ("Do you want to proceed?"). +5. While blocked (Q1 evidence): + - Find the session JSONL under + `~/.claude/projects/-home-mahmoud--claude-jobs-1d53079e-tmp-exp-claude-proj/.jsonl`. + - Copy it aside. Extract every `tool_use`/`tool_result` block with a small Python script. + - Record file mtime and line count three times over several seconds to confirm it is stable. +6. Kill (Q2 setup): identify the exact process tree (pane bash -> `claude` child -> any MCP + subprocess it spawned), verify no other claude matches, then `kill -9` only those PIDs. Re-hash the + JSONL to confirm the kill lost nothing. Confirm `proof.txt` was never written. +7. Resume: in the same pane, same cwd, run + `claude --resume --permission-mode default`. +8. Observe whether the pending permission auto-re-fires. Send a `continue` nudge. Approve any prompt + that appears with `1`. Confirm `proof.txt` now holds the token. +9. Analyze the post-resume JSONL: was the ORIGINAL `toolu_` id answered with a real `tool_result`, + or did a NEW `tool_use` id appear? Cross-check the proxy log for how the dangling `tool_use` was + repaired on the wire. + +## Experiment C, Level 2: the ACP path (claude-agent-acp) + +This is the path the runner actually ships on. Driven by a ~180-line Node ACP client +(`acp_client.mjs`) that speaks newline-delimited JSON-RPC over the adapter's stdio (the framing the +ACP SDK uses). Run from the `services/runner` directory so the adapter resolves its SDK dependency. + +1. Spawn daemon D1: `node .../claude-agent-acp/dist/index.js` with cwd `proj3/`, the proxy env, and + the scrubbed env. Client capabilities advertise `fs.readTextFile`/`fs.writeTextFile`. +2. `initialize` (protocolVersion 1) -> `session/new` (cwd) -> capture `sessionId`. +3. `session/prompt` with the gated Bash command. The client is wired to HOLD (never answer) the + `session/request_permission` request when it arrives. +4. When `session/request_permission` arrives for the Bash `toolCallId`, snapshot the JSONL and record + the id. Do not answer. +5. `kill -9` daemon D1 (simulating the sandbox dying with the gate parked). Re-read the JSONL. +6. Spawn a fresh daemon D2. `initialize`. Then `session/load` with the recorded `sessionId` AND + `_meta.claudeCode.options.resume = ` (the resume id must travel in that meta path; a + bare top-level `sessionId` no-ops for resume). +7. Observe the replay: does the adapter re-emit the original dangling call as a live + `session/request_permission`, or as a settled `tool_call` update? +8. `session/prompt` "continue". This time the client APPROVES any permission request (selecting the + `allow_once` option). Record which `toolCallId`s fire, whether the original id is among them, and + whether `proof.txt` ends up with the token. +9. Analyze the final JSONL and the proxy call signatures. + +## Experiment P: Pi (static source analysis) + +Pi is not on the host. Rather than stand up a container (and risk disturbing the dev stack), Pi's +persistence and load paths were read directly from the bundled, unminified source: +`@earendil-works/pi-coding-agent/dist/core/agent-session.js`, `.../session-manager.js`, +`@earendil-works/pi-agent-core/dist/agent-loop.js`, +`@earendil-works/pi-ai/dist/providers/transform-messages.js`, and the `pi-acp/dist/index.js` +adapter. An existing on-disk session file under `~/.pi/agent/sessions/` confirmed the JSONL shape. +Findings and exact line citations are in the report, clearly labeled static-only. + +## Teardown + +Killed the tmux session, the proxy process (and its `uv`-spawned python child), and both ACP +daemons. Verified port 8791 free, no stray `claude`/proxy processes in the scratch cwds, and that +other agents' sessions were untouched. No containers were started. All outputs are new, uncommitted +files. diff --git a/docs/design/agent-workflows/projects/harness-session-resume/experiments/report.md b/docs/design/agent-workflows/projects/harness-session-resume/experiments/report.md new file mode 100644 index 0000000000..d9a2dbf04f --- /dev/null +++ b/docs/design/agent-workflows/projects/harness-session-resume/experiments/report.md @@ -0,0 +1,190 @@ +# Findings: does a killed-and-resumed harness turn make the same LLM calls as a warm one? + +Date: 2026-07-09. Box: hetznerdev. Steps: [protocol.md](protocol.md). + +## The question in one line + +We want this invariant: whether a human approval takes 10 seconds (warm parked process) or 12 hours +(process killed, resumed later), the sequence of LLM API calls is identical. Call N ends with the +model returning a `tool_use` block. Call N+1 sends the same message list plus the real `tool_result`. +Nothing is regenerated in between. The LLM API is stateless, so this is achievable in theory if the +exact native message list survives and the harness can answer the pending `tool_use` after a restart. +The unknowns are harness behavior, captured as Q1 and Q2 below. + +Rubric for Q2: +- (A) the same `tool_use` id is answered or re-asked -> overnight approvals can be exact, invariant + achievable cold. +- (B) the call is settled as interrupted and the model re-issues a NEW `tool_use` with full + structured context -> near-invariant, small drift risk. +- (C) load fails or drops back to the last user message -> overnight case unrecoverable from harness + disk. + +## Verdicts + +| Harness | Path | Q1 (write timing) | Q2 (dangling call) | Rubric | +|---|---|---|---|---| +| Claude Code | CLI `--resume` | pending `tool_use` on disk mid-turn: YES | original id abandoned, model re-issues NEW id | B | +| Claude Code | ACP `loadSession` (shipping path) | pending `tool_use` on disk mid-turn: YES | original id settled with a synthetic ERROR `tool_result`, model re-issues NEW id | B | +| Pi | source (static-only) | pending `tool_call` on disk mid-turn: YES | original id settled with synthetic error `tool_result` ("No result provided", `isError`), model re-issues | B | + +Bottom line: both harnesses are B, on both the CLI and the ACP path for Claude. The strict invariant +(same call N+1, same id, real result) is NOT achievable from harness disk alone after a cold restart. +The pending tool call is always settled as interrupted, and the model issues a brand-new `tool_use` +to actually do the work. This is a clean, reproducible answer, not a maybe. + +## Q1 evidence: the pending tool_use is on disk mid-turn (both harnesses) + +### Claude Code (live) + +While the CLI sat blocked on the Bash permission prompt, the session JSONL already held the pending +call as its last line, with no `tool_result`: + +``` +line7 assistant tool_use id=toolu_01KGkMsMzioWXztY3RzrnBhu name=Bash + input={"command": "echo TOKEN-8f3a > proof.txt", "description": "Write TOKEN-8f3a to proof.txt"} +last line type: assistant (no tool_result present) +``` + +The file mtime and line count were stable across repeated polls while blocked (one flush mid-turn, +not rewritten). After `kill -9`, the JSONL was byte-identical (same md5, same 8 lines): the hard kill +lost nothing, and `proof.txt` was never created (the tool never ran). The proxy confirmed call N had +been sent and no `tool_result` call N+1 followed, because the harness was blocked. Q1 for Claude: +YES, per-message flush, pending call durable. + +### Pi (static source) + +`agent-session.js` (lines 269-281) persists on the `message_end` event, per message, via a +synchronous `appendFileSync` in `session-manager.js` (`appendMessage -> _appendEntry -> _persist`). +In the agent loop (`pi-agent-core/dist/agent-loop.js`), `streamAssistantResponse` emits `message_end` +for the finalized assistant message (line 105) strictly before `executeToolCalls` runs the permission +gate (line 117, then line 372). So the assistant message carrying the pending `tool_call` is flushed +to disk before the tool or permission resolves. Q1 for Pi: YES, per-message, pending call durable. +(One nuance: entries before the first assistant message are buffered in memory and flushed together +when that first assistant message arrives, so either way the pending call and all prior context land +on disk at that moment. The only exception is the non-persistent in-memory session mode.) + +## Q2 evidence: the dangling call is settled, and a NEW tool_use does the work + +### Claude Code CLI `--resume` + +On resume, the CLI loaded the transcript and waited. It did NOT auto-re-fire the pending permission. +It injected a synthetic `Continue from where you left off.` user turn, and the model replied `No +response requested.` The original `tool_use` was abandoned. Only after a fresh user prompt did the +model issue a NEW `tool_use` (different id, same command), which ran on approval. Final transcript: + +``` +L8 assistant TOOL_USE id=toolu_015rmfzPDgphtX1N23Lw55yt (original, dangling) +L10 user "Continue from where you left off." (injected by resume) +L11 assistant "No response requested." (original call settled as a no-op) +L12 user "continue" (fresh nudge) +L14 assistant TOOL_USE id=toolu_01CwLWTJNYZ4LySs8YPT2qhH (NEW id, same command) +L15 user TOOL_RESULT for=toolu_01CwLW... err=False (real result, NEW id) +L16 assistant "Done - proof.txt now contains TOKEN-9c2b" +``` + +On the wire (proxy), the original id `toolu_015rm...` never appears after resume: the dangling +assistant turn is collapsed to plain text before the message list is sent to the API. The original +call is neither answered nor re-sent. + +### Claude Code ACP `loadSession` (the path the runner ships on) + +Same outcome, reached slightly differently, and this is the load-bearing result because it is the +shipping path. Sequence observed via the ACP client and confirmed in the JSONL and proxy: + +- D1 emitted `session/request_permission` for `toolCallId=toolu_013MPJsaqRPW8t1HkmYmHYd2`. We held it + and `kill -9`ed D1. The JSONL grew from 7 to 8 lines at the kill: the SDK recorded a synthetic + error `tool_result` for the pending call as the stream closed. +- D2 `session/load` (with the resume in `_meta.claudeCode.options.resume`) replayed the original + call and marked it `failed`, not as a live permission request. +- On the `continue` prompt, the model issued NEW `tool_use` ids and eventually ran the command. + +Final JSONL: + +``` +L6 assistant TOOL_USE id=toolu_013MPJsaqRPW8t1HkmYmHYd2 (original) +L7 user TOOL_RESULT for=toolu_013MP... err=True "Tool permission request failed: ..." +L10 user "Continue from where you left off." +L11 assistant "No response requested." +L15 user "continue" +L17 assistant TOOL_USE id=toolu_01XYAgN7hye5ehESpabwaBKf (NEW; errored on a stream-close race) +L19 assistant TOOL_USE id=toolu_01AYcTuzYirdvssznVNG4FwT (NEW; succeeded) +L21 user TOOL_RESULT for=toolu_01AYc... err=False "(Bash completed with no output)" +``` + +`proof.txt` ended with `TOKEN-acp7`. The command did run after resume, so the ACP path is fully +recoverable end to end. But it recovered by re-issuing, not by answering the parked call. + +### The proxy diff (warm vs cold invariant), ACP path + +Cold call N+1, as sent to the API (request signatures, `!err` = `is_error`): + +``` +call#11 assistant[tool_use#toolu_013MP] -> user[tool_result#toolu_013MP!err] +call#15 assistant[tool_use#toolu_013MP] -> user[tool_result#toolu_013MP!err] + assistant[tool_use#toolu_01AY ] -> user[tool_result#toolu_01AY] (real work, NEW id) +``` + +A warm turn (never killed) would instead send, for that same call N+1: +`assistant[tool_use#toolu_013MP] -> user[tool_result#toolu_013MP]` with the REAL command output and +nothing regenerated. The cold path breaks the invariant in two visible ways: the original id's +`tool_result` is a synthetic error, not the real output, and a brand-new `tool_use` id is appended to +carry out the work. The message lists are not identical. + +### Pi (static source) + +`pi-ai/dist/providers/transform-messages.js` (lines 122-182) inserts synthetic tool results for +orphaned calls at the LLM boundary: for any pending `tool_call` with no matching result it pushes a +`toolResult` with the ORIGINAL `toolCallId`, content `"No result provided"`, `isError: true`. The +load path itself (`session-manager.js` `buildSessionContext`, `agent-session-runtime.js` line 166) +preserves the dangling call verbatim in structured history and does not truncate or error. So Pi, on +resume, shows the model `assistant(tool_call X, original args) -> toolResult(X, error) -> (new +prompt)`, and the model is free to re-issue. Full structured context, original call marked errored, +model re-issues: verdict B. This synthetic result is applied in memory at each request and is not +written back to the JSONL. The `pi-acp` adapter advertises `loadSession: true` and its +`session/load` handler hands the existing JSONL to Pi through this same path, so the ACP behavior +matches. This is static-only; no live Pi run was performed. + +## The most surprising finding + +The pending `tool_use` survives a hard `kill -9` perfectly on both harnesses (Q1 is a clean yes), yet +neither harness will ever answer that surviving call. Q1 gives us exactly the durable state the +invariant needs, and Q2 throws it away: on load, the parked call is force-settled as interrupted or +errored, and the model must re-derive a fresh `tool_use` from the still-present user request. The +harness disk is necessary but not sufficient. Half A of the resume plan (the file surviving) is +solved for free; the exact-continuation half is blocked by harness policy, not by lost state. + +## What this means for the design + +- Parkable warm gates give the exact invariant. If the harness process stays alive with the + permission RPC held open (the keep-alive / session-keepalive slice), approval simply unblocks the + waiting `canUseTool` promise and call N+1 answers the original id with the real result. That is the + only path to a byte-exact call sequence. It works only within the warm TTL. +- Cold restart past the TTL cannot be byte-exact from harness disk. Both Claude Code (CLI and ACP) + and Pi settle the parked call and re-issue. The best achievable cold behavior is B: same + conversation, same user intent, full structured history preserved, but a new `tool_use` id and one + extra model round to regenerate the call. Small drift risk (the model could choose slightly + different arguments), bounded because the original user request text is still in context. +- Therefore the durable-decision cold path stays the answer past the warm TTL. The harness-session + resume plan's Half B (record the `agentSessionId`, send `session/load` instead of `session/new`) + is confirmed to work on the ACP path: `loadSession` with `_meta.claudeCode.options.resume` loads + the file, replays history, and the turn continues to completion. But "continues" means re-issues, + not resumes-in-place. The plan should not promise an identical call sequence across a cold restart; + it should promise a faithful continuation (B), and keep cold replay (or a durable approved-decision + record) as the mechanism that makes the re-issued call deterministic rather than a fresh model + guess. +- Practical composition: keep-alive holds the gate for exact continuation inside the TTL; session + resume (this project) makes the next-turn context real and high-fidelity after the sandbox dies; + and a recorded approval decision removes the drift risk of the re-issued call in the cold case. + These three layers are complementary, which matches the plan's own framing. + +## Caveats and limits + +- The Claude runs used a Claude Max subscription and model `claude-fable-5`; behavior is a property + of the harness and SDK, not the model, so this generalizes, but the exact wording of the injected + "Continue from where you left off" / "No response requested" turns is CLI/SDK-version specific + (claude 2.1.205, claude-agent-acp 0.23.1). +- The ACP adapter logged non-fatal `EMFILE` watcher errors (too many open files while watching + `~/.claude`); they did not affect session creation, load, or the turn. Worth noting for the runner, + which may hit the same file-watch pressure under load. +- Pi is static-only. The source is unambiguous and the `pi-acp` load path routes through it, but a + live Pi kill/resume run was not performed. diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/README.md b/docs/design/agent-workflows/projects/pi-approval-parking/README.md new file mode 100644 index 0000000000..3c620d101e --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/README.md @@ -0,0 +1,31 @@ +# Pi approval parking (Option C): planning workspace + +Make both Pi approval gates parkable: within the keep-alive approval TTL, a human's answer +resumes the exact original tool call on the live session, the same guarantee the Claude ACP +gate got in keep-alive slice 2. The mechanism is the proven Option C of the parkable-gates +design: the gate rides `ctx.ui.confirm` with a JSON envelope through the `pi-acp` bridge and +arrives as a real ACP permission request the existing park machinery holds. + +## Files + +- [context.md](context.md): why this exists, the invariant, goals, non-goals, coordination + constraints (JP's backend warm-session move), reading order. +- [research.md](research.md): the verified mechanics with current file:line anchors. How the + two Pi gates pause today, the slice-2 park machinery being reused, where classification + happens, what the spike proved, extension delivery and flags. +- [plan.md](plan.md): the envelope contract, five slices (0: live daemon confidence run, + 1: envelope + classification, 2: extension switch, 3: park + resume, 4: QA), the + removed/kept/added delta table, the full warm/cold behavior matrix, rollout and + compatibility, follow-ups, test inventory. +- [open-questions.md](open-questions.md): six questions with working defaults; #1 and #2 + gate slice order. +- [status.md](status.md): progress source of truth. + +## Related + +- The design this implements: [../session-keepalive/followups/parkable-gates/](../session-keepalive/followups/parkable-gates/) + (options, trade-offs, the spike evidence under `spike-option-c/`). +- The parent feature: [../session-keepalive/](../session-keepalive/) (the pool, TTLs, the + slice-2 approval park for Claude). +- The complementary tier: [../harness-session-resume/](../harness-session-resume/) + (faithful continuation after the process dies; experiments under `experiments/`). diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/context.md b/docs/design/agent-workflows/projects/pi-approval-parking/context.md new file mode 100644 index 0000000000..0107e25078 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/context.md @@ -0,0 +1,77 @@ +# Pi approval parking: context + +## Why this exists + +The runner's approval story is judged by one invariant: whether a human answers an approval in +ten seconds or after hours, the sequence of LLM API calls should be identical. Call N ends with +the model returning a tool call; call N+1 appends the real result for that same call; nothing +is regenerated in between. Both production approval failures (argument drift, task restart) +came from breaking it: the runner destroyed the session on pause, replayed flattened text into +a fresh agent, and the model re-issued a new call whose arguments drifted from the stored +decision. + +Keep-alive slice 2 (merged, #5158) fixed this for one gate: the Claude ACP permission gate. +The runner parks the live session with the permission request held open and answers it when +the human clicks, so the original call runs with its original arguments. Pi has no such path. +Both Pi gates (the custom-tool relay gate and the builtin permission gate) destroy the session +the moment an ask fires, and every Pi approval resumes through cold replay with the drift risk +that implies. + +The parkable-gates design +([../session-keepalive/followups/parkable-gates/design.md](../session-keepalive/followups/parkable-gates/design.md)) +evaluated the options and a live spike on 2026-07-09 proved the chosen one, Option C: raise +the Pi gate as a real ACP permission request through the extension-UI dialog plane that the +`pi-acp` bridge already has, and let the existing slice-2 park machinery hold it. The spike +held a gate open for three minutes and resumed the original call with its original arguments +([../session-keepalive/followups/parkable-gates/spike-option-c/report.md](../session-keepalive/followups/parkable-gates/spike-option-c/report.md)). + +This project is the implementation of that decision. + +## Goal + +Both Pi approval gates become parkable: inside the keep-alive approval TTL, a human's answer +resumes the exact original tool call on the live session (the byte-exact tier). Outside it, +everything degrades to today's cold decision-map path, unchanged. + +Concretely: + +- The in-sandbox extension raises the gate as `ctx.ui.confirm` carrying a JSON envelope with + the real gate identity, instead of blocking on the file-relay poll. +- The runner classifies that request by parsing the envelope, decides allow/deny instantly + from the existing permission plan, and parks only a genuine ask. +- The parked session resumes through the existing approval-resume dispatch; the answer + resolves the held dialog and the original call proceeds inside the original `prompt()`. +- Fail-closed everywhere: any cancellation, timeout, eviction, or transport failure blocks the + tool; nothing ever runs unapproved. + +## Non-goals + +- The client-tool MCP pause (Gate 3 of the parkable-gates design). Different transport, + separate work, gated on an unmeasured Claude client timeout. +- Harness session resume (`session/load`). Owned by the harness-session-resume project; it is + tier 2 of the same invariant and composes with this work rather than replacing it. +- The durable interactions plane resolver (answers from other surfaces, hours later). The + durable rows this feature writes are unchanged. +- Changing `pi-acp` upstream. The JSON envelope makes the current bridge sufficient; the + upstream structured-metadata field is a recorded cleanup, not a dependency. +- Daytona keep-alive. The session pool does not park Daytona sandboxes today (keep-alive + slice 3 is deferred); this feature inherits that boundary and must degrade cleanly there. + +## Constraints and coordination + +- **JP owns the backend warm-session move and harness session resume.** Those efforts reshape + the same pause, park, and resume code. This implementation lands on top of, or inside, that + work; the park record lives wherever the pool lands. Do not build against the runner-local + pool without checking the current state of that migration first. +- **Flag-off must be byte-identical to today.** The same rule keep-alive itself follows. +- **The wire contract does not change.** The `interaction_request` event and the `/run` + request shapes stay as they are; if any field must change, the golden-fixture procedure in + `services/runner/CLAUDE.md` applies. + +## Reading order for an implementer + +1. This file, then [research.md](research.md) (the verified mechanics and every code anchor). +2. [plan.md](plan.md) (slices, deltas, the warm/cold matrix, tests). +3. The parkable-gates design and the spike report, linked above, for the full option analysis + and raw evidence. +4. [open-questions.md](open-questions.md) before starting: two items gate slice order. diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md b/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md new file mode 100644 index 0000000000..2930e02898 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md @@ -0,0 +1,49 @@ +# Pi approval parking: open questions + +Items marked DECIDED are settled (the 2026-07-09 plan review resolved several); the rest +state a working default. #1 still gates slice order. + +1. **The live daemon confidence run (slice 0). OPEN, gates everything.** The spike proved the + hop against `pi-acp` directly; the runner ships through the sandbox-agent daemon. Source + says the daemon leg is a passthrough (`acp-http-client@0.4.2`, fail-closed) and that the + daemon maps replies by kind, but neither has been run live. Slice 0 checks both: the + envelope arrives intact at a runner-side handler, and `respondPermission(id, "once")` + resolves the held dialog to allow. Working default: run slice 0 first; if it fails, fall + back to the parkable-gates design's Option B and stop. + +2. **Approval-card rendering for the envelope identity. OPEN, verify in slice 4.** The plan + normalizes the tool-call id at the top of `handleRequest` and synthesizes the card payload + from the envelope (real tool name via `resolvedName`, real arguments as `rawInput`), so + the card should render exactly like a relay-gate card does today. The remaining check is + frontend-side: confirm the card renders correctly and the fold-back of the decision keys + on the normalized id end to end. If the frontend keys on anything else in `toolCall`, + that is a slice-1 finding, not a wire change. + +3. **Where the dialog-allow is recorded for the double-gate. DECIDED.** The responder and + the relay share the same per-turn `ConversationDecisions` object + (`sandbox_agent.ts:1218/1225/1268`), but it is write-closed (`take`/`peek` only, + `responder.ts:209-237`). The mechanism is a new FIFO append API with consume-1-append-1 + accounting on the cold dialog path, the key-parity invariant tested + (`envelope.toolName === spec.name`, `envelope.input` === the exact execute params), and a + slice task for the warm-resume path, which bypasses the responder: verify whether + `extractApprovalDecisions` already seeds the map from the resume request, else append + explicitly on the resume branch before `sandbox_agent.ts:1389`. Details in plan.md + slice 2. + +4. **Flag coupling. RESOLVED (2026-07-10): there is no flag.** The dialog gate shipped as + the unconditional Pi behavior and the relay permission plumbing was deleted with it, so + the half-state question dissolved: without keep-alive the gate still decides instantly + with real card identity, and an ask degrades to the cold durable-decision path. + +5. **Deny-warm UX. OPEN, a product call, not a mechanism question.** On a warm deny the hook + returns `blockReason` and Pi continues its loop on the live session; the model sees the + block and may try something else. Mechanically sound (and id-correct after the slice-1 + normalization); confirm during slice 4 that continuing live is the wanted UX versus + ending the turn on a deny. + +6. **Malformed envelope under the `agenta-approval` title. DECIDED: fail closed.** A + title-matching request whose envelope does not parse is answered with an immediate + reject. The earlier working default (fall through to the spec-less path, "worst case is a + paused turn") was wrong: under a default-allow permission plan the fallthrough resolves + to allow and the dialog would confirm an unapproved execution. Strict parse plus reject + makes the worst case a blocked tool call, which is the correct worst case. diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/plan.md b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md new file mode 100644 index 0000000000..35d42d8440 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md @@ -0,0 +1,255 @@ +# Pi approval parking: implementation plan + +Read [context.md](context.md) and [research.md](research.md) first; this plan assumes their +facts and cites code anchors from research.md without repeating the reasoning. + +## The shape of the change, in one paragraph + +Both Pi gates stop expressing an approval as a file-relay wait and raise it as +`ctx.ui.confirm(title, message)` from inside the sandbox, where `message` is a JSON envelope +carrying the real gate identity. The `pi-acp` bridge turns that dialog into a real ACP +`session/request_permission`, which lands at `attachPermissionResponder`, the same seam that +already holds, parks, and answers Claude gates. The runner parses the envelope into a proper +`GateDescriptor`, decides allow/deny instantly from the existing permission plan, and parks a +genuine ask with the slice-2 machinery (`parkedApproval`, pool, approval-resume dispatch). On +approve, `respondPermission` resolves the held dialog, the blocked hook returns, and the +original tool call runs inside the original still-open `prompt()`. The relay keeps doing what +it does well: delivering tool execution and results. Only the approval wait moves. + +## The envelope (the one new contract) + +The envelope is sandbox-internal (extension to runner, through the dialog message string). It +is not on the `/run` wire, so no golden-fixture change; it still deserves interface care +because two codebases parse it. + +```json +{ + "v": 1, + "kind": "agenta.gate", + "gate": "pi-builtin" | "pi-custom-tool", + "toolName": "", + "toolCallId": "", + "input": { } +} +``` + +Field roles: `v` and `kind` are protocol context (version + discriminator, so an unrelated +`confirm` from some future extension never misclassifies); `gate` is routing (which gate +raised it, for logging and for the executor field of the `GateDescriptor`); `toolName`, +`toolCallId`, `input` are the gate identity (data). The envelope carries identity only, never +policy: the permission metadata (`specPermission`, `readOnlyHint`) is recovered runner-side +from the run's own resolved specs (slice 1), because the sandbox is not trusted to state its +own permissions. + +Parsing is strict, and a parse failure FAILS CLOSED, not through. The dialog `title` is fixed +to `agenta-approval` as the cheap pre-filter; a request whose title matches but whose envelope +does not parse (wrong `kind`, unknown `v`, malformed JSON) is answered with an immediate +reject. It must NOT fall through to the spec-less handling: under a permission plan whose +default is allow, a spec-less fallthrough resolves to allow, `ctx.ui.confirm` resolves true, +and an unapproved tool runs. A request whose title does not match is not a Pi gate and takes +today's path unchanged. + +The envelope maps to `GateDescriptor` as: `executor: "harness"` for `pi-builtin` (matches how +`relayPermissionCheck` keys builtins today via `piBuiltinIdentity`), `executor: "relay"` for +`pi-custom-tool`, `toolName` and `args` from the envelope. This keeps the stored-decision +keying (`decide` -> `stored.take(gate)`, name plus canonical args) identical across the warm +and cold paths, which is what makes a cold decision answer a re-issued call. + +## Slices + +### Slice 0: the live daemon confidence run (half a day, gates everything) + +The spike drove `pi-acp` directly; the runner ships through the sandbox-agent daemon +(`acp-http-client` in between, source-verified passthrough). Re-run the spike's `allow` and +`hold` scenarios through a sandbox-agent daemon session before writing feature code. Reuse the +committed spike assets +(`../session-keepalive/followups/parkable-gates/spike-option-c/spike-extension.js`, +`acp-client.mjs` adapted to the daemon HTTP API). Pass criteria: the permission request +reaches a runner-side `onPermissionRequest` handler with the envelope intact, and the +three-minute hold still resumes the original call. This run also doubles as the live check on +the reply mechanism: the daemon's `respondPermission(id, reply)` takes `reply` in +`{once, always, reject}` and maps it to the dialog's option BY KIND internally +(`permissionReplyToResponse`, `sandbox-agent/dist/chunk-TVCDKGSM.js:2811`; `PermissionReply`, +`index.d.ts:2976`), so a plain `respondPermission(id, "once")` must resolve the held dialog +to allow. Fail criteria route back to the parkable-gates design's Option B fallback; do not +improvise. + +### Slice 1: envelope parsing and gate classification (runner side) + +- New module `src/engines/sandbox_agent/pi-gate-envelope.ts`: `PI_GATE_DIALOG_TITLE`, + `buildPiGateEnvelope` (shared with the extension via the existing bundling), and + `parsePiGateEnvelope(request) -> {gate, toolName, toolCallId, input} | undefined` (strict, + version-checked). +- **Normalize the tool-call id FIRST.** At the top of `handleRequest` + (`acp-interactions.ts:186`), on an envelope hit, replace `req.toolCall.toolCallId` (the + bridge's synthetic `pi-ui-`) with the envelope's REAL id, before the `[HITL]` logging, + `buildGateDescriptor`, `pauseUserApproval`, and `onPausedToolCall` all read it. Stamping + only the emitted payload is insufficient, and the two failure modes are severe: + `approvalDecisionForToolCall` (`session-pool.ts:231`, called at `server.ts:624`) matches the + incoming decision against the parked id, so a synthetic id means the warm resume NEVER + fires (silently always cold); and `pause.markPausedToolCall` would suppress frames for the + wrong id, letting the real tool call's open part be settled as "not executed" while the + Vercel egress keys approval chunks strictly on the payload's toolCallId (`stream.py:620`), + clobbering the approval card in the frontend. +- `acp-interactions.ts`: on the envelope hit, build the `GateDescriptor` from the envelope + identity and stamp the emitted `toolCall` payload (reuse the `resolvedName` stamping at + `:77-81`, and synthesize the card's `rawInput` from `input` so the approval card shows real + arguments, not envelope JSON). Everything downstream (responder, `decide`, latch, + `pauseUserApproval`, interaction row) is unchanged code. +- **Recover permission metadata runner-side, never from the envelope.** The envelope names + the tool; the runner looks the tool up in the run's own resolved specs by `toolName` (and + through `piBuiltinIdentity` for `pi-builtin`) to fill `specPermission` and `readOnlyHint` + on the `GateDescriptor`, restoring relay parity (the relay gate sets `spec.permission` + today at `relay.ts:240`, and read-only builtin identity at `relay.ts:411-424`; + `effectivePermission` consumes both at `permission-plan.ts:129-135, 248-256`). Without + this, author-allow tools newly pause, author-deny tools route through pause instead of an + instant deny, and read-only builtins get asked instead of auto-allowed. +- **Fail closed on a malformed envelope.** Title matches, envelope does not parse: reply + reject immediately (see the envelope section above for why fallthrough is an unapproved + execution under a default-allow plan). +- No reply-mapping work. The runner never sees the dialog's raw `yes`/`no` option ids; the + sandbox-agent daemon's `respondPermission` maps `{once, always, reject}` to the option by + KIND internally (slice 0 note above). `decisionToReply` (`responder.ts:485`) and the resume + reply at `server.ts:648` are already correct for Pi. Do not widen the + `ParkedApproval`/resume reply types. +- Unit tests: envelope parse (valid, wrong kind, wrong version, hostile strings from the + spike probe), id normalization (gate descriptor, pause bookkeeping, and emitted payload all + see the real id), spec lookup parity (author-allow stays instant-allow, author-deny stays + instant-deny, read-only builtin auto-allows), malformed-envelope reject, non-matching title + untouched. + +Deliverable: dark. Nothing exercises this path until slice 2 turns the extension on, so the +slice ships as unit-tested code with zero behavior change; it is safe for Claude because a +non-matching title takes today's path untouched. + +### Slice 2: the extension switch (sandbox side) + +- `agenta.ts` builtin hook (`:181-207`): when the new flag is on, replace + `relayPermissionCheck` with `await ctx.ui.confirm(PI_GATE_DIALOG_TITLE, + JSON.stringify(envelope))` (no `opts`, so no reaper; fail-closed `false` on any + cancellation). `true` -> allow (`undefined`), `false` -> `blockReason`. +- `agenta.ts` custom-tool wrapper (`:273-276`): when the flag is on, gate BEFORE + `runResolvedTool` with the same dialog; only an allow proceeds to the relay execution. + **Scope: non-client executable tools only.** `registerTools` registers every advertised + spec including `client` tools (`public-spec.ts:43`), and client tools have their own + browser-fulfilled pause semantics through the relay (`dispatch.ts:239`, `relay.ts:214`); + permission-gating a client tool via the dialog would be wrong. The dialog gate applies to + `callback` (and `code`) specs; `client` specs keep today's path untouched. +- Single enforcement point (final, 2026-07-10): the dialog gate is the ONLY Pi permission + check. The relay's permission plane (the watcher's `permissions.decide` on execution + requests, `handlePermissionRelayRequest`, `relayPermissionCheck`, the `kind: "permission"` + record protocol) is DELETED, and the relay carries tool execution and results only. The + earlier draft kept the relay check as flag-gated defense-in-depth and bridged the double + gate with a FIFO re-append into `ConversationDecisions`; with the dialog gate unconditional + there is no flag plumbing whose failure the second check would catch, so the check guarded + nothing and the bridge was its only cost. Both are gone. +- No flag: the dialog gate is the unconditional behavior for every Pi run. Envelope detection + on the runner is scoped to Pi runs structurally (the responder receives the resolved-specs + map only on Pi runs; its presence turns detection on), so a Claude gate whose ACP title + collides with the dialog title still takes the base path. +- Relay scope: a builtin-only run (no custom tools) starts no relay at all; `useToolRelay` + (`run-plan.ts`) is `toolSpecs.length > 0`. +- Tests: extension-level unit tests run through the existing extension test seams (dialog + raised for callback specs, NOT raised for client specs, fail-closed false on cancel, args + validated before the dialog); classification tests for the fail-closed identities (unknown + builtin, unresolved custom tool, malformed envelope) and the Claude title-collision + passthrough. + +Deliverable: both Pi gates ride the dialog plane end to end; asks still pause-and-destroy +(parking arrives in slice 3). + +### Slice 3: park and resume + +- `sandbox_agent.ts`: two changes that only work together. (a) Widen the + `ParkedApproval.gateType` union, today the single literal `"claude-acp-permission"` + (`sandbox_agent.ts:368`), to include `"pi-dialog-permission"`, and record the park with the + envelope identity in `onUserApprovalGate` (`:1321-1338`). (b) Update the `server.ts:628` + guard, which today hard-rejects any parked gate that is not the Claude gate type, to accept + `"pi-dialog-permission"` too. (a) without (b) means Pi always falls cold at the dispatch; + (b) without (a) is a compile error. The pause exemption (`:1145-1152`), pool park, TTL, and + eviction paths need no change; they key on `parkedApproval`, not on the harness. +- `server.ts` resume (`:648-676`): unchanged flow. The reply is already correct + (`"once"`/`"reject"`; the daemon maps by kind, slice 0 note). For a custom-tool gate, the + slice-2 warm-resume decision seeding applies so the follow-on relay execution passes. +- Multi-gate: no assertion needed, the degrade is already safe and harness-agnostic. + `approvalToPark` refuses to park when `approvalGateCount > 1` (`server.ts:407`), so a + hypothetical parallel second dialog degrades the whole turn to the cold path, and the + unemitted second dialog dies with the destroyed session, fail-closed. Document this as the + designed behavior; Pi's sequential loop makes it a non-case in practice. +- Tests: dispatch tests mirroring the slice-2 keep-alive suite (park on ask, resume-approve + runs the original call, resume-reject blocks it, TTL expiry falls cold, approval-mismatch + evicts). Fake-session seams already exist (`SandboxAgentDeps`, `createAgentServer(run)`). + +Deliverable: the invariant holds for Pi warm approvals. This is the feature. + +### Slice 4: QA and the live matrix + +Run the warm/cold matrix below live on the dev box (playground + programmatic), flag on and +off, plus one Daytona run to confirm the degrade row. Record results in status.md. Keep the +existing agent-replay-test practice: pin one recorded warm approve and one cold approve as +regression tests. + +## What is removed, what is kept, what is added + +| Area | Removed (flag on) | Kept | Added | +|---|---|---|---| +| Extension (`agenta.ts`) | the `relayPermissionCheck` call in the builtin hook; the naked `runResolvedTool` for gated NON-CLIENT custom tools | tool registration; `runResolvedTool` relay EXECUTION (results still flow over the relay files); client tools' browser-fulfilled path untouched | the dialog gate (`ctx.ui.confirm` + envelope) at both gates, non-client specs only, unconditional | +| Relay (`relay.ts`, `dispatch.ts`, `run-plan.ts`) | the whole permission plane: `relayPermissionCheck`, `handlePermissionRelayRequest`, the `kind: "permission"` record protocol, `RelayPermissions` and the watcher's `permissions.decide` enforcement; builtin-only runs stop starting the relay (`useToolRelay` = custom tools only) | the watcher and execution dispatch (execute requests + results, client-tool pass-through) | none | +| Responder seam (`acp-interactions.ts`) | nothing | all pause/park/reply mechanics | envelope detection + tool-call id normalization at the top of `handleRequest` + `GateDescriptor` from envelope with runner-side spec lookup + card payload synthesis + malformed-envelope reject | +| Reply mapping (`responder.ts`) | nothing | everything (`decisionToReply` is already correct; the daemon maps `{once, always, reject}` to the dialog option by kind) | nothing | +| Park record (`sandbox_agent.ts`, `server.ts`) | nothing | everything | `gateType` union widened to include `"pi-dialog-permission"` (`sandbox_agent.ts:368`) + the `server.ts:628` gate-type guard accepts it + envelope identity in `parkedApproval` | +| Stored decisions (`responder.ts` `ConversationDecisions`) | nothing | keying (name + canonical args), `take`/`peek` (the dialog gate's cold decision map) | nothing (the planned double-gate FIFO append died with the relay permission plane) | +| Config | nothing | `AGENTA_RUNNER_SESSION_KEEPALIVE`, TTLs, pool cap | nothing (no flag: the dialog gate is the unconditional Pi behavior) | +| Wire contract | nothing | everything (`interaction_request` shape unchanged; card payload uses existing fields) | nothing (assert with the existing wire-contract test) | + +## The warm/cold behavior matrix + +"Warm" means keep-alive on, session parked, answer inside the approval TTL. Every other cell +is cold. The dialog gate itself has no flag (always on for Pi); KA = +`AGENTA_RUNNER_SESSION_KEEPALIVE` still gates the pool and the parking. + +| Scenario | Behavior | +|---|---| +| Warm approve (KA on, within TTL) | The resume answers the held dialog; the hook returns allow; the original call runs with its original arguments inside the original `prompt()`; call N+1 carries the real result for the original id. Byte-exact. | +| Warm deny | The resume answers `no`; the hook returns `blockReason`; the tool call reports failed; the turn continues live on the same session (Pi handles the block in-loop). Nothing executes. | +| Approve after TTL (cold) | The park expired and the session was destroyed (the held dialog died with it, fail-closed). The decision lands on today's cold path: cold replay, the model re-issues the call, `decide` consumes the stored decision by name plus canonical args. After harness session resume lands, the same but with full structured history (rubric B); either way the decision map absorbs drift by re-firing the gate on mismatch. | +| Deny after TTL (cold) | Same path; the stored deny blocks the re-issued call. | +| ACP transport drop mid-pending | The spike's drop scenario: `pi-acp` and Pi die cleanly, nothing executes. The pool's parked-promise rejection evicts the slot; the next message runs cold. Degradation target is tier-2 session resume once that project lands (the pending call is already on Pi's disk). | +| TTL expiry racing an approval | The pool's existing race handling: expiry destroys and the late decision misses the pool (`approval-mismatch`/pool-miss path) and degrades to the cold decision map. The durable row was written at pause time, so the answer always lands. No new code; covered by an existing-pattern test. | +| KA off | The dialog gate still decides instantly (allow/deny from the responder, real card identity), but an ask pauses and destroys the session (no pool), and the held dialog dies with it, fail-closed. Cold decision-map resume: the durable path. | +| Daytona (any flags) | The pool does not park Daytona sandboxes (keep-alive slice 3 deferred), so every Daytona ask is the "KA off" row: pause, destroy, cold decision-map resume. The dialog transport itself works on Daytona (the extension and env flow are identical, research.md §6), so when slice 3 lands, Daytona parking needs no Pi-specific work. | + +## Rollout and compatibility + +- **No image skew for the extension.** The runner installs `agenta.js` per run from its own + bundle (research.md §6); runner and extension deploy atomically. The only mixed state is a + session created before a deploy and resumed after it; the pool's config fingerprint and the + restart-drains-pool behavior make that a cold resume, which both transports handle. +- **On by default, no flag.** The dialog gate is the only Pi permission path; the relay + permission plumbing is deleted in the same change (the runner installs the extension per + run, so both sides switch atomically). Keep-alive off still degrades every ask to the cold + durable-decision path, so the fail-closed story does not depend on the pool. +- **pi-acp is pinned** (0.0.29). The dialog reaper behavior and the extension-UI translation + are version-load-bearing; a Pi or pi-acp upgrade must re-run the spike's hold scenario + (this is in the risks of the parkable-gates design; repeat it in the upgrade checklist). + +## Recorded follow-ups (out of scope) + +1. Upstream a structured-metadata field to `pi-acp` (maintainer Sergii Kozak, svkozak/pi-acp) + or carry a pnpm patch, retiring the envelope encoding. +2. Daytona parking (keep-alive slice 3) picks up Pi parking for free; verify then. + +## Test inventory (summary) + +- Unit: envelope build/parse round-trip (incl. the spike's hostile probe string), request + classification (envelope vs non-matching title), tool-call id normalization everywhere the + id is read, runner-side spec lookup parity (author-allow instant, author-deny instant, + read-only builtin auto-allow), malformed-envelope reject (fail closed), unknown builtin and + unresolved custom-tool reject (fail closed), client-spec exclusion from the dialog gate, + the Claude title-collision passthrough. +- Dispatch (fake session): Pi ask parks; resume-approve runs original call once (instant AND + warm-resume decision seeding for the relay's second check); resume-deny blocks; TTL expiry + -> cold; approval-mismatch evicts; multi-gate refuses the park and degrades cold + (`server.ts:407` behavior, harness-agnostic). +- Wire contract: assert no `/run` or `interaction_request` shape change (existing tests). +- Live (slice 4): the matrix above on the dev box, plus the pinned replay regressions. diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/research.md b/docs/design/agent-workflows/projects/pi-approval-parking/research.md new file mode 100644 index 0000000000..a35eb56f95 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/research.md @@ -0,0 +1,169 @@ +# Pi approval parking: research + +Everything here is verified against `services/runner/src` as of 2026-07-09 (post the #5178 and +#5183 merges) unless a package path says otherwise. A reader should be able to implement from +this file plus plan.md without re-deriving the mechanics. + +Landed note (2026-07-10, PR #5185): the mechanics in §1 describe the code this feature +REPLACED. The relay permission plane (§1's `relayPermissionCheck`, the watcher's permission +handling) is deleted; both Pi gates now ride `ctx.ui.confirm` unconditionally, with no flag +(the §7 flag paragraph is historical). + +## 1. How the two Pi gates pause today (the code being replaced) + +Pi tools and gates ride a file relay because the in-sandbox Pi process cannot reach Agenta. +The extension (`src/extensions/agenta.ts`, bundled to `dist/extensions/agenta.js` by +`pnpm run build:extension`) writes request files; the runner watches the relay directory and +writes response files (`startToolRelay`, `src/tools/relay.ts:449`). The watcher is started per +turn (`src/engines/sandbox_agent.ts:1355-1360`) and stopped at turn end (`:1428`). + +**The custom-tool gate.** The extension registers every resolved tool with Pi +(`agenta.ts:249-286`; requires `AGENTA_AGENT_TOOLS_RELAY_DIR`, so ALL Pi runs, local and +Daytona, execute custom tools through the relay). A tool's `execute` calls `runResolvedTool` +with `relayDir` (`agenta.ts:273-276`), which relays the call (`src/tools/dispatch.ts:66-111`: +write `.req.json`, poll for `.res.json`, deadline `RELAY_TIMEOUT_MS` = 60 s). The +gate lives runner-side: the watcher's `executeRelayedTool` runs `permissions.decide(gate)` +before executing (`relay.ts:240-264`). On `pendingApproval` it calls +`permissions.onPendingApproval` and returns the `PAUSED` sentinel; no response file is +written. + +**The builtin gate.** A Pi `tool_call` hook (`agenta.ts:181-207`) blocks on +`relayPermissionCheck` (`dispatch.ts:131-213`), which writes a `kind:"permission"` relay +request and polls. The watcher answers a builtin ask IMMEDIATELY with +`verdict:"pendingApproval"` (`relay.ts:433-446`); the hook has only allow and deny to express +that with, so it returns a `blockReason` (`agenta.ts:199-200`), a deny. Fail-closed by +construction (`dispatch.ts:129`). + +**The pause destroys the session.** `onPendingApproval` (`sandbox_agent.ts:1272-1293`) emits +the `interaction_request` event, records the durable pending interaction, and calls +`pause.pause()`. The pause controller's destroy callback (`sandbox_agent.ts:1135-1157`) keeps +a session alive only when `env.parkedApproval` was recorded (`:1152`), and the only code that +records it is the Claude ACP hook (`:1321-1338`). A Pi ask never records one, so the session +(and the in-sandbox poll inside it) dies moments after the ask. The 60-second deadline is +never the mechanism; teardown is. + +## 2. The slice-2 park machinery this feature reuses + +All merged and live behind `AGENTA_RUNNER_SESSION_KEEPALIVE` (#5156, #5158). + +- **The park record.** `env.parkedApproval` (`sandbox_agent.ts` `ParkedApproval`, ~371-381): + `gateType` (today only `"claude-acp-permission"`), `permissionId`, `toolCallId`, `toolName`, + `args`, `interactionToken`, plus the held `promptPromise`. Recorded by the + `onUserApprovalGate` callback (`:1321-1338`) in keep-alive park mode only + (`opts.approvalParkMode`). +- **The pause exemption.** The destroy callback skips teardown when `approvalParkMode && + env.parkedApproval` (`:1145-1152`); the dispatch then parks the session or, if it refuses + (multi-gate `approvalGateCount > 1`, pool full), calls `env.destroy()`. +- **The pool.** `session-pool.ts`: `park` (`:533`), `checkoutApproval` (`:471`), `repark` + (`:492`), the approval TTL (`DEFAULT_APPROVAL_TTL_MS = 300_000`, `:46`), + `approvalDecisionForToolCall` (`:231`, finds the allow/deny for a specific parked tool-call + id in the incoming request), pool key (`poolKeyFor`, `:366`, runContext-preferred). +- **The resume dispatch.** `server.ts` `awaiting_approval` branch (`:604-693`): validates the + decision match, the history fingerprint, and the parked mount credentials' expiry + (deliberately NOT config-fingerprint or credential-epoch equality, `:608-621`), then calls + `engine.runTurn(live.environment, ..., { approvalParkMode: true, resume: {...} })` + (`:658-676`). Inside `runTurn`, the resume answers the held request via + `env.session.respondPermission` (`sandbox_agent.ts:1389`) and awaits the original + `promptPromise`. +- **The reply mapping needs no change.** `decisionToReply` (`src/responder.ts:485-493`) + produces `"once"`/`"reject"`, and that is correct for Pi too: the runner talks to the + sandbox-agent daemon, whose `respondPermission(id, reply)` takes `reply` in + `{once, always, reject}` and maps it to the request's option BY KIND internally + (`permissionReplyToResponse`, `sandbox-agent/dist/chunk-TVCDKGSM.js:2811`; + `PermissionReply`, `index.d.ts:2976`). The raw `yes`/`no` option ids the spike saw came + from driving `pi-acp` directly, below the daemon. A literal `respondPermission(id, "yes")` + would fall to the mapper's else branch and select a REJECT option: approvals would become + denials. Do not map ids; do not widen the reply types. +- **The stored-decisions object is shared but write-closed.** The responder and the relay + consume the SAME per-turn `ConversationDecisions` object (built once, + `sandbox_agent.ts:1218`; consumed at `:1225` and `:1268`), which is what makes the + double-gate bridge possible. But it exposes only `take`/`peek` over private + `decisionQueues` (`responder.ts:209-237`); the bridge needs a new FIFO append API + (plan.md, slice 2). + +## 3. Where ACP permission requests are classified + +`attachPermissionResponder` (`src/engines/sandbox_agent/acp-interactions.ts:53-241`) is the +single entry point for every ACP `session/request_permission`: + +- `handleRequest` (`:186-240`) builds a `GateDescriptor` via `buildGateDescriptor` + (`:263-289`). A request with no embedded spec gets `executor: "harness"` and takes its + `toolName` from `spec.name -> recorded tool_call name -> name -> title -> kind` (`:269-275`). + **A Pi dialog request has no spec and its title is the dialog title**, so without envelope + parsing it would classify as `toolName="agenta-approval"` with dialog-string args. That + identity would flow to the approval card, the durable interaction row, and the stored + decision map. Wrong everywhere. Envelope parsing must happen here. +- The verdict comes from `responder.onPermission` -> `decide(gate, plan, stored)` + (`src/permission-plan.ts:138-158`); a stored decision is consumed by `stored.take(gate)` + (`:147`), keyed on tool name plus canonical arguments. +- `pendingApproval` routes to `pauseUserApproval` (`:88-120`): fires `onUserApprovalGate` + (the park recorder), acquires the single-pause latch, emits `interaction_request`, records + the durable interaction, and calls `onPause` (never replying to the harness; teardown or a + park resolves the RPC). +- Allow/deny reply immediately via `session.respondPermission` (`:148-166`). + +This is the load-bearing integration fact: **the Pi dialog request arrives at exactly the +seam that already knows how to hold, park, and answer a gate.** The work is classification +(the envelope), not mechanism. + +## 4. What the spike proved (and its one residual) + +Full evidence: +[../session-keepalive/followups/parkable-gates/spike-option-c/report.md](../session-keepalive/followups/parkable-gates/spike-option-c/report.md) +(protocol, extension, client, raw wire transcripts alongside). Summary of the load-bearing +facts: + +- **The hop works mid-gate** (Q1). `ctx.ui.confirm` raised inside a `tool_call` hook arrives + as a real `session/request_permission` while the tool call is pending; the answer resolves + the blocked hook. Path: extension -> Pi RPC `extension_ui_request` -> `pi-acp` + `handleExtensionConfirm` -> `conn.requestPermission` (`pi-acp/dist/index.js:1106-1128`). +- **The park is the default** (Q3). Held 180067 ms with no reaper; the late allow ran the + original call with its original arguments inside one uninterrupted `prompt()`. Pi's dialog + helper arms a timeout only if the caller passes `opts.timeout` or an aborting signal + (`pi-coding-agent/dist/modes/rpc/rpc-mode.js:44-80`); pass neither. +- **The payload survives as a JSON envelope** (Q2). Natively the bridge forwards only + `{method, title, message}` with a synthetic `toolCallId` `pi-ui-` + (`pi-acp/dist/index.js:565, 1130-1144`). A JSON envelope through the `message` field + round-trips byte-exact (quotes, backslashes, Japanese, newlines tested). +- **Fail-closed on every unhappy path** (Q4). Deny and a rejected ACP request both resolve the + dialog to `false`; a transport drop kills `pi-acp` and Pi cleanly with nothing executed. +- **The daemon leg is source-verified, not live-run** (Q5). `acp-http-client@0.4.2` forwards + `requestPermission` unchanged with a fail-closed no-handler fallback + (`dist/index.js:448-452`). One live run through the sandbox-agent daemon is the recorded + residual and this plan's slice 0. +- The dialog's ACP options are `[{optionId:"yes", kind:"allow_once"}, {optionId:"no", + kind:"reject_once"}]` (`CONFIRM_PERMISSION_OPTIONS`). The runner never sees those ids: + the daemon's kind-based reply mapping (§2) absorbs the difference, so the existing + `once`/`reject` replies are already correct. + +## 5. What the kill-and-resume experiments bound + +[../harness-session-resume/experiments/report.md](../harness-session-resume/experiments/report.md): +the pending call survives a hard kill on disk (Pi flushes the assistant message on +`message_end`, before the gate runs), but no load path answers it; every cold resume settles +the parked call and the model re-issues (rubric B). Consequences for this feature: warm +parking is the only byte-exact tier, so this work cannot be replaced by session resume; and a +parked Pi session that dies degrades to a faithful tier-2 continuation, not a lost turn. + +## 6. Extension delivery and compatibility + +The extension ships INSIDE THE RUNNER, not in a sandbox image. `pi-assets.ts` copies the +esbuild bundle (`dist/extensions/agenta.js`, `pi-assets.ts:27`) into the per-run Pi agent dir: +local at `:117` (`installPiExtensionLocal`), Daytona at `:187` (written into the sandbox). +Runner and extension therefore version together; there is no old-image-new-runner skew for the +extension itself. The gate transport is chosen per run by env vars the runner sets +(`AGENTA_AGENT_TOOLS_RELAY_DIR` etc. today), so the new transport can be gated the same way, +and both transports coexist in one extension during rollout. + +## 7. Environment and flags today + +- `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off) gates the pool; approval parking is park + mode within it. The pool does not park Daytona sandboxes (keep-alive slice 3 deferred). +- `AGENTA_AGENT_TOOLS_RELAY_TIMEOUT` sets the relay poll deadline (`relay.ts:61-63`). The + dialog path never touches it. +- The extension's env is built by `buildPiExtensionEnv` (`pi-assets.ts:67-78`), which is + where `AGENTA_AGENT_BUILTIN_GATING` is set. (Historical: the plan draft routed the new + transport through a flag pair here; the landed change has no flag, the dialog transport is + unconditional for Pi.) +- Whether a run starts the relay at all is `useToolRelay` (`run-plan.ts`); with the dialog + transport a builtin-only run starts no relay (custom tools only). diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/status.md b/docs/design/agent-workflows/projects/pi-approval-parking/status.md new file mode 100644 index 0000000000..8b5876a2bc --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/status.md @@ -0,0 +1,107 @@ +# Pi approval parking: status + +Source of truth for progress. Keep this current. + +## Current state (2026-07-09) + +- Phase: planned, not started. This workspace was created from the parkable-gates design + (`../session-keepalive/followups/parkable-gates/`) after its Option C was proven by the + live spike the same day; it ships in PR #5153 alongside that design. +- The plan is implementation-ready pending two gates: the slice-0 live daemon confidence run + (open-questions.md #1) and a coordination check with the backend warm-session move + (context.md, constraints). + +## Decisions inherited (do not relitigate here) + +- Mechanism: Option C, the ACP permission plane via `ctx.ui.confirm` plus the JSON envelope. + Options and trade-offs live in the parkable-gates design; the evidence lives in its + `spike-option-c/` folder. Option B (park the relay wait) is the recorded fallback if + slice 0 fails. +- Judged by the call-sequence invariant; warm parking is the only byte-exact tier + (kill-and-resume experiments, `../harness-session-resume/experiments/report.md`). +- Approval TTL default 5 minutes; resume validation checks decision + history + mount expiry + (slice-2 realities, parkable-gates design "How this composes"). + +## Provenance + +- 2026-07-09: workspace created (context, research with current code anchors, plan with four + slices plus the slice-0 gate, open questions, this file). Research verified against + `services/runner/src` post #5178/#5183. Two implementation-relevant findings made during + planning, folded into plan.md: the reply-option mismatch (`decisionToReply` falls back to + `once`/`reject`; the Pi dialog offers `yes`/`no` and needs kind-based selection), and the + double-gate (a dialog-allowed custom tool still hits the relay watcher's + `permissions.decide`; bridged by writing the consumed allow into the turn's stored + decisions). The first finding was OVERTURNED by the plan review the same day (see below); + the second was confirmed and deepened. + +## Plan review round (2026-07-09, Codex xhigh) + +Architecture approved; 3 blockers and 4 must-fixes folded into plan.md, research.md, and +open-questions.md the same day. All findings were code-verified by the reviewer: + +- B1: the planned reply-option mapping was wrong and is DELETED. The sandbox-agent daemon's + `respondPermission` maps `{once, always, reject}` to the dialog option by kind internally + (`permissionReplyToResponse`, chunk-TVCDKGSM.js:2811; `PermissionReply`, index.d.ts:2976); + implementing the plan's mapping would have turned approvals into denials (a literal "yes" + falls to the mapper's reject branch). The spike saw raw ids only because it drove `pi-acp` + directly, below the daemon. Slice 0 now doubles as the live check on this mechanism. +- B2: slice 1 must normalize `req.toolCall.toolCallId` to the envelope's real id at the top + of `handleRequest`, or the warm resume never fires (`approvalDecisionForToolCall`, + `session-pool.ts:231`, matched at `server.ts:624`) and pause suppression mis-keys, + clobbering the FE approval card (the Vercel egress keys on payload toolCallId, + `stream.py:620`). Stamping only the emitted payload is insufficient. +- B3: slice 3 must widen the `ParkedApproval.gateType` literal (`sandbox_agent.ts:368`) AND + update the `server.ts:628` guard that hard-rejects non-Claude gate types; either alone + fails (compile error / always cold). +- M1: the envelope carries identity only; permission metadata (`specPermission`, + `readOnlyHint`) is recovered runner-side by spec lookup (plus `piBuiltinIdentity`), + restoring relay parity; otherwise author-allow tools newly pause and read-only builtins + get asked. +- M2: the double-gate bridge premise holds (shared `ConversationDecisions`, + `sandbox_agent.ts:1218/1225/1268`) but the object is write-closed; the plan now specifies + a FIFO append API, consume-1-append-1 accounting on the cold dialog path, the key-parity + invariant, and a warm-resume seeding verification (`extractApprovalDecisions`) or an + explicit append before `sandbox_agent.ts:1389`. +- M3: the dialog gate is scoped to non-client executable tools; client tools + (`public-spec.ts:43` registers them too) keep their browser-fulfilled pause path + (`dispatch.ts:239`, `relay.ts:214`). +- M4: a malformed envelope under the matching title replies reject (fail closed); the + earlier fall-through default was an unapproved-execution hole under a default-allow plan. + open-questions #6 corrected accordingly. +- Doc corrections folded: flag plumbing lives in `buildPiExtensionEnv` + (`pi-assets.ts:67-78`), name pair `AGENTA_RUNNER_PI_DIALOG_GATE` -> + `AGENTA_AGENT_PI_DIALOG_GATE`; multi-gate documents the harness-agnostic safe degrade + (`approvalToPark` refuses count > 1, `server.ts:407`) instead of asserting count == 1; + builtin-only runs can skip the relay (`useToolRelay`, `run-plan.ts:447`); slice 1's + deliverable reframed as dark (unit-test-only until slice 2 turns the extension on). +- Retired in open-questions.md: the reply-plumbing concern (resolved opposite to the draft), + concurrency (safe degrade documented), warm-deny mechanics (sound after B2; the UX product + call stays open as #5). + +## Implementation (2026-07-09/10, lane feat/pi-approval-parking, PR #5185) + +Slices 0-3 implemented; slice 4 (live QA) runs as a separate stage. + +- Slice 0 PASSED both criteria live through the sandbox-agent daemon on the dev box: the + envelope arrived byte-exact at a runner-side `onPermissionRequest` handler (hostile probe + string included), and a plain `respondPermission(id, "once")` resolved the held dialog to + allow so the original `park_probe` ran with its original token, immediately and after a + 180-second hold with no reaper. The daemon exposed `availableReplies: ["once","reject"]`, + confirming B1 (no reply mapping); the synthetic `pi-ui-` id on the ACP request + confirmed B2 (normalization required). +- Slices 1-3 landed per plan.md, then two review rounds (an internal Opus review, a Codex + xhigh review, CodeRabbit) tightened the fail-closed identities: unknown builtin AND + unresolved custom-tool names reject; envelope detection is scoped to Pi runs structurally. +- Scope change (Mahmoud, 2026-07-10): the dialog gate shipped WITHOUT a feature flag as the + only Pi permission path, and the relay permission plane was deleted (extension + `relayPermissionCheck` poll, `handlePermissionRelayRequest`, the `kind: "permission"` + record protocol, `RelayPermissions` enforcement, the planned double-gate FIFO bridge). + The relay carries tool execution and results only; a builtin-only run starts no relay. + `ParkedApproval.gateType` for the Pi plane is `"pi-acp-permission"` (symmetric with + `"claude-acp-permission"`). + +## Next steps + +1. Slice 4: the live warm/cold matrix on the dev box (separate QA stage). +2. JP coordination: the park record moves with the pool wherever the backend warm-session + work lands (checked 2026-07-09: no active migration in flight). diff --git a/docs/design/agent-workflows/projects/session-keepalive/README.md b/docs/design/agent-workflows/projects/session-keepalive/README.md new file mode 100644 index 0000000000..c4e0c929cf --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/README.md @@ -0,0 +1,21 @@ +# Session keep-alive + +Keep the harness session alive for a TTL after a turn ends, so the next message (or an approval decision) continues the same live session with full native memory. Cold replay stays as the universal fallback. Flag-gated, local-only first, runner-only. + +## Files + +- [context.md](context.md): why this exists, goals, non-goals, background, and how it relates to the other features. +- [architecture-notes.md](architecture-notes.md): the deep companion. Three parts: how the runner works today (the current flow, the process tree, the teardown, the four approval gates), what keep-alive changes (before/after with worked examples), and every design decision with its problem, options, trade-offs, and reason. Read this to understand the feature. +- [plan.md](plan.md): the plan. The Q&A answering Mahmoud's questions (with measured costs and the Claude-vs-Pi approval story), the slice sizes and risks, the one-hour assessment, flags, failure modes, and the verification plan. Builds on architecture-notes.md by reference. +- [open-questions.md](open-questions.md): decisions that refine defaults and edge behavior; none blocks slice 1. +- [status.md](status.md): progress, decisions, provenance, measured research figures, the drift check against current code, and recorded follow-ups. The meta and provenance home. +- [followups/](followups/): incremental designs that build on keep-alive, each a full design in its own subfolder. [parkable-gates/](followups/parkable-gates/) makes the Pi and client-tool approval gates survive a turn the way the Claude gate now does. [in-place-reconfiguration/](followups/in-place-reconfiguration/) changes a live session's config (model, skills, AGENTS.md, tools) without tearing it down. Both land after slices 1 and 2. + +## Start here + +Read context.md, then architecture-notes.md (Part 1 explains how things work today before anything changes), then plan.md. + +## Related + +- [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md): why the agent has no memory. This feature is Part 3, option 2. +- [../harness-session-resume/plan.md](../harness-session-resume/plan.md): option 3, the complementary feature. Build it after keep-alive. diff --git a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md new file mode 100644 index 0000000000..5d840e8d75 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -0,0 +1,361 @@ +# Session keep-alive: how the runner works today, what changes, and why + +This is the deep, code-grounded companion to [plan.md](plan.md). It has three parts: + +1. **How the runner works today.** The current flow, end to end, with the real process tree and the real teardown. A cold reader needs this before anything else makes sense. +2. **What keep-alive changes.** The same flows, before and after, with worked examples for the two cases that matter (a follow-up message when a live session exists, and when it does not). +3. **The design decisions.** Each one as a problem, the options we weighed, the trade-offs, the choice, and the reason. + +The idea in one paragraph: today the runner destroys the whole agent process tree at the end of every turn, so the next message starts from nothing and the agent re-reads a flattened text copy of the conversation. Keep-alive holds that process tree alive for a short time after a turn ends. If the next message in the same conversation arrives inside that window, the runner sends only the new user text to the still-running agent, which still has its full native memory. If the window has passed, or anything about the request does not match, the runner falls back to today's cold path. Nothing can get worse than today. + +Everything below is verified against `services/runner/src` as of 2026-07-08. Provenance, review history, and QA notes live in [status.md](status.md), not here. + +--- + +# Part 1: How the runner works today + +## The runner is one long-lived process + +The runner is a Node service that stays running. It listens on port 8765 and answers one HTTP contract: a `/run` request comes in, a stream of events goes out (`src/server.ts`). Every deployment runs exactly one copy of it (one replica in both the docker-compose and the Helm defaults). It already keeps state in memory between requests: a set of in-flight sandboxes, a replica id, and session-affinity keys (`src/sessions/alive.ts`). So a per-process pool of live sessions is something the runner can hold today with no new infrastructure. + +## What one turn builds today + +Every `/run` request runs `runSandboxAgent` (`src/engines/sandbox_agent.ts`). For each request it builds a fresh agent environment from scratch, runs the turn, then tears the whole thing down. The build step (roughly today's lines 328 to 699) does a lot: + +- signs short-lived mount credentials with the sessions API, +- derives a durable working directory for the conversation, +- starts a sandbox, +- mounts the working directory, +- prepares the workspace and skills, +- starts an internal tool server (a small MCP HTTP server the harness calls back into), +- and opens the agent session with `createSession`. + +`createSession` is where the process tree comes to life. For a local (non-Daytona) run, three operating-system processes get spawned per session, one nested inside the next: + +``` +runner (the long-lived Node service) +└─ sandbox-agent daemon (one per session, spawned by the local provider) + └─ ACP adapter subprocess (claude-agent-acp, or pi-acp for Pi) + └─ the harness process (the Claude Code CLI, or `pi --mode rpc`) +``` + +This is one full tree **per session**, not a shared pool. The daemon spawns a fresh adapter, and the adapter spawns a fresh harness, for every `createSession`. Nothing is reused between requests today. (Measured footprint of this tree is Decision 9 below.) + +The harness process is the agent. It holds the real conversation memory: the tool calls it made, the results it saw, and its own thinking. That memory lives only inside that process. + +## What one turn tears down today + +`runSandboxAgent` ends in a `finally` block (`sandbox_agent.ts` lines 1004 to 1047 in the pre-refactor code) that runs on every exit, success or failure. It performs the full cleanup, in order: + +1. stop the tool relay, +2. abort any in-flight calls into the internal tool server (`mcpAbort.abort()`), +3. close the internal tool server, +4. send a graceful `session/cancel` to the harness (`destroySession`), +5. destroy the sandbox, +6. dispose the daemon handle (this SIGTERMs then SIGKILLs the daemon), +7. unmount the durable working directory, +8. remove the temporary directories. + +Step 4 exists because of a real incident (the 2026-07-06 child-process leak, covered in Decision 8). Killing the daemon does not cascade the kill to the adapter it spawned, so the adapter and harness would reparent to PID 1 and never exit. Sending `session/cancel` first lets them shut down cleanly. + +The key fact for this whole design: teardown today is **guaranteed** because it lives in a `finally`. Every exit path runs it. Keep-alive removes that guarantee on purpose (it defers teardown to a timer), which is why the shared teardown path and its triggers are the riskiest part of the work. + +## Why the agent has no memory across turns + +Because the process tree dies at the end of every turn, the next turn has nowhere to continue from. The runner cold-starts a brand new tree and hands the harness the whole conversation as one flattened block of text (`buildTurnText`). The harness never sees its own structured history. It reads a transcript, the way a new employee reads a handover note. + +Worked example (today, two normal turns): + +- **Turn 1.** You ask the agent to read three files and summarize them. The runner builds a tree, the harness reads the files (real tool calls, real results in its memory), it answers, and the runner destroys the tree. +- **Turn 2.** You ask "now do the same for the fourth file." The runner builds a **new** tree. The new harness has no memory of turn 1. It receives a text transcript that says turn 1 happened. It has to reconstruct what "the same" means from that text. It cannot see the actual tool results it produced last time, because those lived in a process that no longer exists. + +That loss is tolerable for a plain question. It is not tolerable for an approval, which is where it caused two production failures. + +## How an approval works today + +An agent sometimes wants to run a tool that needs a human's yes or no. The full permission model is in [../approval-boundary/how-approvals-work.md](../approval-boundary/how-approvals-work.md). What matters here is that the runner has **four different gate mechanisms**, and they pause in structurally different ways. This difference is the whole reason the approval fix (Part 3, Decision 6) applies to one harness first. + +| Gate | Harness | How it pauses today | +|---|---|---| +| ACP permission gate | Claude | The harness sends the runner a blocking permission request over the protocol. The runner holds it as a pending promise and can answer it (`respondPermission(id, reply)`) at any later moment. | +| Custom-tool relay gate | Pi | The tool call blocks **inside the sandbox**, polling a file for a response, with its own 60-second deadline (`RELAY_TIMEOUT_MS`). | +| Builtin gate | Pi | A hook inside the Pi process blocks synchronously, inside the sandbox, waiting on the same relay. | +| Client-tool MCP pause | Claude (client tools) | The runner **aborts** the in-flight HTTP call to its internal tool server. The call is destroyed, not held. | + +Only the Claude ACP gate leaves the runner holding something it could answer later. Keep that in mind for Decision 6. + +Today, whichever gate fires, the runner reacts the same way: it destroys the session (`PendingApprovalPauseController.pause()`, `pause.ts` lines 24 to 29) and ends the turn with `stopReason: "paused"`. It has to end the turn, because a live Claude session never resolves its `prompt()` call while a gate is open; leaving it open would hang the run and leak the sandbox. + +So the human clicks Approve, and now there is no session to resume. The frontend re-sends the whole conversation with the approval answer folded in. The runner cold-starts a fresh tree, replays the flattened transcript, and waits for the new harness to re-issue the same tool call. When it does, the runner matches the re-issued call against a stored decision, keyed by the tool name plus the exact JSON of the arguments. + +Worked example (today, an approval): + +- Turn 1: the agent wants to post a Slack message. The gate fires. The runner destroys the session, ends the turn paused. You see an Approve button. +- You click Approve. The frontend re-sends the conversation. +- Turn 2: the runner cold-starts a new harness, replays the transcript. The **new** harness must reconstruct the Slack call from text and re-issue it. If its regenerated arguments are byte-for-byte identical to what turn 1 produced, the stored decision matches and the tool runs. If the new harness phrases the message even slightly differently, the match fails, the gate re-fires, and you see a second Approve button for a call you already approved. + +Both production failures came from this: the re-issued call drifted from the approved one, and in one case the whole task restarted. The root cause is that the session that made the original call no longer exists at approval time. + +--- + +# Part 2: What keep-alive changes + +## The core change + +When a turn ends, the runner does not tear the tree down. It parks the environment in an in-memory pool for a short time (a TTL). The environment keeps running, so the harness keeps its full native memory. The next request in the same conversation looks up the pool by a key derived from the conversation. On a match, the runner sends only the new user text to the live harness. On any miss, it falls back to exactly today's cold path. + +Two TTLs, because two situations need different windows: + +- **Idle TTL** (recommended default 60 seconds): how long a session waits for the next normal message. +- **Approval TTL** (recommended default 5 minutes): how long a session parked on an open approval waits for a human to click. This is configurable; a Daytona deployment will likely use a smaller value (Decision 9). + +## Before and after: a normal follow-up message + +**The case where a live session exists (the win).** + +- Today: turn 2 cold-starts a new tree and hands it a flattened transcript. The agent reconstructs context from text. +- With keep-alive: the runner finds the parked session under the conversation key, confirms the config and history still match (Decision 2), and calls `session.prompt(newUserText)` on the live harness. The harness already remembers turn 1 natively. No transcript is flattened. In live QA on the dev box this took the follow-up turn from about 25 seconds (cold) to about 3 seconds (continue). + +**The case where no live session exists (the fallback).** + +- The user waited longer than the idle TTL, or this is the first message, or the config changed. The pool lookup misses. The runner does exactly what it does today: cold-start, flatten, replay. The user notices nothing except the normal cold latency. This path is always available and always correct. + +## Before and after: an approval + +- Today: the pause destroys the session. After the click, a fresh harness must re-issue the call and hope its arguments match a stored key. This is where turns failed. +- With keep-alive (inside the approval TTL): the pause parks the session instead of destroying it. The session keeps holding the open permission request and the suspended `prompt()` promise. When the human clicks Approve, the runner answers the still-open request with `respondPermission(parkedId, "once")`. The original prompt continues. The original tool call runs, with its original byte-exact arguments. No new harness re-issues anything, so drift and task-restart cannot happen. +- Fallback: if the approval TTL expires before the click, the parked session is destroyed and the click lands on today's cold decision-map path. The result is exactly today's behavior, no better and no worse. + +## The universal fallback + +Every path in keep-alive degrades to cold replay. A miss, a mismatch, a dead session, a busy session, a raced second turn, an expired TTL, a validation failure: all of them fall through to the path the runner already runs today. A validation bug can only cost a cold restart. It can never produce a wrong continuation or fail a turn. + +--- + +# Part 3: The design decisions + +Each decision below states the problem, the options, the trade-offs, the choice, and why. + +## Decision 1: key the pool on project plus session, not session alone + +**Problem.** The pool needs a key that identifies "the same conversation." The obvious candidate is the conversation id (`session_id`) that the frontend already mints per chat tab and sends on every request (`protocol.ts`, resolved by `resolveRunSessionId`). But that id is supplied by the caller on the wire. Two callers in two different projects could send the same `session_id` string. If the key were the id alone, a request from project B could look up and continue a live agent that belongs to project A, running with project A's credentials and memory. That is a cross-project leak. + +**Options.** +- (a) Key on `session_id` alone. Simplest, but unsafe for the reason above. +- (b) Key on a project scope plus `session_id`. Safe, but the runner needs a trustworthy project id, and no project id rides the `/run` wire for playground runs today (the Python adapter forwards none, and `server.ts` assumes its absence). +- (c) Add a project id to the wire. Safe, but it is a wire change, and the whole feature is meant to be runner-only. + +**Choice: option (b), with the project scope taken from the mount-sign response.** When the runner signs mount credentials, the sessions API returns a mount object that includes `mount.project_id`. That value is server-verified, not caller-supplied, so it is a project scope the runner can trust. The mount helper (which today keeps only the credentials) is extended to surface it, and the pool key becomes `:`. + +**Consequence and why.** If mount signing is unavailable (the store is unconfigured, the request falls back to an ephemeral working directory, or signing returns an error), there is no trustworthy project scope, so **that session is never parked**. It runs fully cold, exactly as today. This is a deliberate safety default: no verified owner means no reuse. It also means keep-alive silently does nothing on a deployment where mount signing is broken, which is a real thing that happened during QA (see status.md); the tell is the absence of a `[keepalive] park` log line. + +**Is taking the project id from the mount a mixing of responsibilities? Partly yes, and here is the honest picture.** The mount signer's job is to sign credentials for a working directory, not to tell the pool who owns a conversation. It surfaces `project_id` only because the pool needs a trustworthy project scope and, today, the mount response is the only place the runner sees one. The `/run` wire does carry a nominal `projectId` field, but the live playground path never populates it and the runner never reads it, so it is not a real source. The project scope does exist one layer up: the Python agent service already holds it as a server-derived value on the request state (it resolves it from the caller credential, never from the request body, the same way it resolves vault secrets). It is just not put on the wire today. + +So there are three ways to give the runner a trustworthy project scope: +- (a) Surface it from the mount response. No wire change, but it couples the pool key to the mount signer, which is the coupling this concern names. +- (b) Stamp it into `runContext`, the object the service already computes server-side to describe the run's own identity (its workflow and trace ids). A project id belongs there, next to the workflow id. Because the service computes `runContext` from request state, it is exactly as server-verified as the mount value, and the caller cannot forge it. This is a small, clean wire addition: the DTO, the two mirrors, the goldens, and one line in the service that already holds the value. +- (c) Resolve it server-side from the run credential with no wire field at all, the way the runner's own heartbeat and stale-interaction calls already resolve project scope. + +**Choice for v1: (a), with (b) recorded as the cleanup.** Option (a) ships keep-alive as a runner-only change with no wire work, which is the point of the feature's first cut. But the reviewer's instinct is right that the mount is the wrong long-term owner of this value. Option (b) is the clean home and is a small change, so it is recorded as a follow-up in status.md, to land when keep-alive stops being strictly runner-only. The safety objection that a caller could forge a project id applies only to a raw wire field, not to option (b): the service stamps `runContext` from server-side state, the same way it already stamps the workflow identity. Until the cleanup lands, the no-mount-no-park rule above still holds, so (a) is safe as the interim source. + +## Decision 2: the two fingerprints, and what problem they solve + +**Problem.** A parked session is a frozen agent with a specific configuration and a specific history. The next request might not be a clean continuation of it. The config might have changed (the user edited the agent's model, prompt, or tools between messages). The history might not line up (a different client, or a resend of an older state). If the runner blindly continued the live session on a request that does not actually follow from it, the agent would answer with the wrong setup or the wrong memory. So before continuing, the runner has to prove the incoming request is a true continuation of the parked session. Two fingerprints do that. + +**Fingerprint 1, config.** A hash over the config-bearing fields of the request: harness, sandbox, model, provider, deployment, endpoint, credential mode, the system prompts and `AGENTS.md`, tools, skills, custom tools, MCP servers, permissions, the workflow revision id and version, and the draft flag. It deliberately excludes the per-turn volatile fields (the messages, the turn id, trace propagation headers, rotating telemetry headers, and secret values). If the config fingerprint differs, the configuration changed, and the parked session is the wrong setup to continue. + +**Fingerprint 2, history.** A hash over the ordered prior user-message texts plus the ordered tool-call ids, plus a count of prompts so far. These survive the round trip byte-stable, so they identify "this request continues exactly the conversation the parked session has lived through." If the history fingerprint differs, this request is not the next step of that session. + +**What happens when the user changes the config mid-conversation.** This is a real case: the user sends a message, then edits the model or a tool in the playground, then sends another message in the same chat. The `session_id` is unchanged, so the pool lookup hits. But the config fingerprint now differs. Three options exist: (a) update the live session to the new config in place, (b) continue with the old config and ignore the change, or (c) evict the parked session and cold-start a fresh one with the new config. + +Option (b) is wrong: it silently ignores the user's edit. + +Option (a) is the better end state, and it is possible in principle. The current runner integration bakes most config into the process at `createSession`, and neither the harness nor the `sandbox-agent` session exposes an in-place reconfigure step today, so (a) is not available yet. But "not available yet" is not "impossible." Several config dimensions are workspace files or dynamic surfaces that a live process could re-read without a new session (AGENTS.md, skills, an MCP server's tool list), and even the model can change mid-conversation without a respawn, the way the cloud playground already lets you switch models and keep talking (you lose the prompt cache, not the conversation, and you do not reformat prior messages into ACP). Building that is its own design, written up in [followups/in-place-reconfiguration/design.md](followups/in-place-reconfiguration/design.md). + +Option (c) is what v1 does. It evicts the parked session, destroys it, and cold-starts with the new config. The user gets exactly what they configured, at the cost of one cold turn. It is the simplest correct behavior, and it is safe because a cold turn is always correct. So for v1 a config change is a cold turn, by design, with in-place reconfiguration as the planned follow-up that removes that cost. + +**What happens on no match, in general.** Any fingerprint miss degrades to cold replay. The design never tries to force a continuation it is unsure about. The worst case of a fingerprint bug is an unnecessary cold restart, never a wrong answer. A unit test pins the history fingerprint to the exact message array the server receives (the frontend prunes answer-less assistant turns before sending), so a future change to the frontend's pruning rule trips a test instead of silently causing misses. + +**Making a cold fall-through visible, so it is never silent.** A fingerprint miss is safe, but it is also invisible by default, and that is its own risk. A bug that sent every turn cold would still produce correct answers, so no one would notice the feature had stopped working and every conversation had gone slow. This already bit us once in QA: a broken mount migration made every run fall back to cold, and the only tell was the absence of a `park` log line (status.md). The runner logs the path it took on each request (`[keepalive] hit-continue`, `miss`, `park`, `expire`, `evict`), which is enough for a human reading logs and not enough for the person watching the playground or the inspector, who cannot see logs and has no signal that a "continue" silently became a "cold start." + +The fix is to surface the path each run took as first-class run metadata, not just a log line. Two options. First, a trace attribute on the run span (for example `ag.keepalive.path = hit | miss | park | cold`), which the inspector and any trace view can read after the fact. Second, a small meta event on the `/run` stream, which the frontend could render live ("continuing" versus "starting fresh"). The trace attribute is the cheaper, higher-value one: it needs no wire change, and it turns "did keep-alive silently stop working" into a query you can run over traces. The stream meta event is a later nicety, tied to the same frontend "setting up sandbox" surface already recorded as a follow-up. Both are recorded in status.md; neither blocks v1, but the trace attribute should land with slice 1 so the feature is observable from day one. + +## Decision 3: what the runner does on each incoming request + +**Problem.** On a request whose key hits the pool, the runner has to decide among several outcomes. It also has to handle two clients racing on the same conversation. + +**The decision, in order:** +- **Continue a normal turn.** Both fingerprints match, and the tail of the request is a fresh user message. The runner calls `session.prompt(newUserText)`. No flattening. +- **Resume an approval.** Both fingerprints match, the parked session holds an open Claude permission request, and the new content is an approval answer whose tool-call id matches the parked gate. The runner answers the parked request (Decision 6). +- **Go cold.** Anything else: a miss, a fingerprint mismatch, a dead session, a busy session, or a gate shape the runner cannot answer. Evict, destroy, run today's path. + +**First, what "stop" does today, because it frames the race.** When you click stop in the playground, the frontend aborts its own stream connection and nothing else. It does not call the runner. For an agent chat, which is a session-owned run, the runner deliberately does not treat a dropped connection as a cancel: the harness keeps running to completion in the background, and the disconnect only tells keep-alive whether to park the session afterward. So today "stop" stops the view, not the work. There is no per-turn cancel wired end to end; the runner has only a process-wide `/kill` for the orphan sweeper, not a "cancel this turn" call. This matters for the race below, because the natural expectation, stop the running turn but keep the conversation, is not something the stack can do yet. + +**Two clients racing the same conversation.** Suppose the user has two playground tabs open on the same conversation, or sends a second message while the first is still running. Both requests carry the same key. The parked session can only run one turn at a time (the harness is a single process). Three options: +- (a) Reject the second turn with an error. Simple, but it breaks the rule "never fail a turn." +- (b) Supersede by destroying the busy session and cold-starting the second turn. The second turn runs cold; the first turn's result is abandoned. +- (c) Supersede by aborting the busy turn's prompt and continuing the second turn on the same live session. The second turn keeps native memory; nothing goes cold. + +Option (c) is what the reviewer's expectation describes, and it is the better end state, because it matches "stop the running turn, keep the conversation, send the new message." It needs two things the stack does not have yet: a real per-turn cancel that aborts the in-flight `prompt()` (the abort machinery exists in the engine, but no cancel call is wired to it), and confidence that a `sandbox-agent` session is clean and resumable after a mid-prompt abort. Both are unproven today. + +**Choice for v1: (b), supersede by cold-start.** It honors "never fail a turn" and needs nothing new. A `busy` flag on the session (a single-threaded check-and-set, safe because Node runs the dispatch on one thread) makes the race detectable: a request that arrives for a busy session evicts it and runs cold. The cost is that the second turn loses native memory. Option (c) is recorded as the follow-up that pairs with a real per-turn stop, because the two are one piece of work: once the runner can cleanly abort a turn and keep the session, both "stop" and "supersede without going cold" fall out of it. + +**Why a busy flag and not the backend's distributed lock.** The backend already has a distributed session lock, and keep-alive does not replace it or rebuild it. It is worth being precise about what each one is, because they answer different questions. + +The backend's lock is an alive-lock in Redis: "at most one in-flight run per session," cluster-wide, owned by the turn id (`api/oss/src/dbs/redis/sessions/locks.py`). The runner already cooperates with it. The API acquires it at turn start, and the runner refreshes it over HTTP through the session heartbeat (the API is the single Redis writer; the runner never touches Redis directly). That lock keeps its job unchanged under keep-alive. + +The busy flag is a different thing at a different layer. It is not "is this session running somewhere in the cluster." It is "does this one runner replica's in-memory pool currently hold a live, warmed process tree for this key." What it guards is a Node-heap object: a running harness process and its open ACP connection. A Redis key cannot represent that, and cannot hand a second replica the warmed sandbox sitting in the first replica's memory, which is the entire value keep-alive adds. So the flag is not a poor version of the distributed lock; it is the occupancy bit of a local object cache, and a distributed lock is the wrong tool for it. + +On the reviewer's second point, the flag already does what he describes. It does not block a second turn; it detects that the session is busy and supersedes (evict, then run the new turn), rather than making the second caller wait for a result they no longer want. The multi-replica future is where the distributed lock earns its place: route the second turn to the replica that owns the session (through the existing `owner:session` affinity key) and gate the supersede on the distributed running-lock, so cross-replica correctness comes from Redis while local occupancy stays a local flag. That layering is recorded as the long-term design; for a single-replica, single-threaded runner, the flag is the correct fit. + +## Decision 4: split the turn into acquire and run + +**Problem.** Today `runSandboxAgent` builds the environment and runs the turn in one function. To reuse an environment across turns, the expensive build has to be separable from the per-turn work. + +**Choice.** Split it into two functions: +- `acquireEnvironment(request)`: the session-scoped, expensive build (today's lines 328 to 699). It returns an environment that can serve many turns. The internal tool server must live as long as the session, because its URL is baked into the session at creation. +- `runTurn(env, request, emit, signal)`: the per-turn work (today's lines 712 to 986). A fresh trace run, a fresh approval latch, a fresh decision set, then prompt, resolve usage, flush the trace. On a continuation, the prompt is just the new user text, so `buildTurnText` never runs. + +**Incremental finalizers (why a half-built environment cannot leak).** `acquireEnvironment` registers a finalizer for each resource the moment it exists: sandbox started, working directory mounted, tool server up, session created, temp dirs made. If a later step throws, the environment's `destroy()` runs the finalizers registered so far, in reverse order. This is the same `destroy()` the pool uses later, so there is exactly one teardown path, and it is correct whether the environment is fully built or half built. + +## Decision 5: attach the event listeners once, for the life of the session + +**Problem.** Each turn's handlers close over turn-specific state (the trace run, the pause controller, the latch, the decisions). The naive way to reuse a session is to detach turn N's listeners and attach turn N+1's. That is a trap in this package. + +**Why the naive way is wrong.** The `sandbox-agent` listener registries are plain sets. An event that fires with no listener attached is silently dropped. A permission request that arrives with no listener is **cancelled**. So any window between detaching one turn's listeners and attaching the next turn's is a window in which a dropped event or a cancelled approval can occur. + +**Choice.** Attach `onEvent` and `onPermissionRequest` exactly once, in `acquireEnvironment`, for the whole life of the session. Those permanent listeners demux into a mutable "current turn" reference that `runTurn` swaps in at the start of a turn and clears at the end. There is no detach window, so there is no drop or cancel window. Events that arrive while no turn is active (between turns, or after a park) hit a small between-turns handler: a permission request parks (Decision 6) or is cancelled by an explicit policy, and a stray event is logged and dropped by decision rather than by accident. The risk moves from "did we detach in time" (a timing bug, hard to test) to "does every event route to the right turn" (a routing property, easy to test with a fake session). + +**The shape of it, concretely.** The session owns one mutable field, call it the current-turn sink. It is either empty (no turn is running) or it points at the live turn's handlers: that turn's trace run, its pause controller, its approval latch, and its decision set. The two permanent listeners are thin routers. When an event arrives, `onEvent` reads the sink and forwards the event to it; if the sink is empty, it hands the event to the between-turns handler. `onPermissionRequest` does the same for a gate. `runTurn` touches this field in exactly two places: it points the sink at the new turn's handlers before it calls `prompt()`, and it clears the sink when the turn ends (or, on a park, hands ownership to the park record). Nothing ever detaches the listeners themselves. + +Worked example, two turns with a stray event in between: +- Turn 1 starts. `runTurn` points the sink at turn 1's handlers, then prompts. The harness streams events; `onEvent` forwards each to turn 1. Turn 1 ends; `runTurn` clears the sink. +- The session is parked and idle. A late event arrives from the harness (a trailing log line, say). The sink is empty, so `onEvent` routes it to the between-turns handler, which logs and drops it by decision. Nothing is lost by accident, and nothing crashes for lack of a listener. +- Turn 2 starts. `runTurn` points the sink at turn 2's fresh handlers, then prompts. Turn 2's events reach turn 2 only. Turn 1's trace run, already flushed, can never receive a turn 2 event, because the sink no longer points at it. + +This is why the design is testable without a live harness. A fake session fires two turns' worth of events plus a between-turns event, and the test asserts that each turn saw only its own events and that the stray event hit the between-turns handler. The property under test is routing, which a fake can drive deterministically, not timing, which it cannot. + +## Decision 6: the approval win, for Claude today and Pi later + +The four approval gates from Part 1 pause in structurally different ways, and that difference decides which one keep-alive can make reliable first. This section walks each gate: how it pauses today, whether keep-alive can park it, why, and what it would take to extend parking to the gates it cannot yet reach. + +**The mechanism that makes a gate parkable.** A gate is parkable only if, after the turn ends, the runner still holds something it can answer to make the original tool call proceed. The Claude ACP permission gate is the only one that qualifies. When it fires, the runner is holding two things: the pending permission request (kept in the daemon's `pendingPermissionRequests` map, in the runner's own memory) and the suspended `prompt()` promise (the HTTP connection carrying it is held open by a disabled undici timeout in `acp-fetch.ts`, on purpose, so a paused human-timescale turn is not reaped). Both survive the turn ending. So the runner can park the session, and when the human clicks, call `respondPermission` on the still-open request and await the same suspended promise. The original call runs with its original arguments. + +**Why the other three gates cannot park as built.** +- **Pi custom-tool relay gate.** The block is not in the runner. It is a file poll inside the sandbox, running on its own deadline (`RELAY_TIMEOUT_MS`, which defaults to 60 seconds and is set by `AGENTA_AGENT_TOOLS_RELAY_TIMEOUT`). The runner holds no promise. By the time the turn ends, the Pi side is either still spinning on a file that will never appear or has already timed out. There is nothing in the runner to answer. Raising the timeout does not change this. A longer bounded wait is still a bounded in-sandbox wait, it still dies when the turn's sandbox is torn down, and it is still not answerable after the turn ends. The deadline is there to bound how long the runner may take to execute a normal tool call and write the result back, not to wait for a human; a real human approval short-circuits this loop and ends the turn regardless of the deadline. Parkability is about where the pending state lives, not how long the timer runs. +- **Pi builtin gate.** Same shape, one level in: a hook inside the Pi process blocks synchronously on the relay. A synchronous block inside the harness cannot survive a turn boundary, and again the runner holds nothing. +- **Client-tool MCP pause.** The runner actively **aborts** the in-flight HTTP request when the tool pauses (`tool-mcp-http.ts`). The request is destroyed on purpose (so the harness cannot clobber the pending widget). By turn end there is no held request to answer. + +**Choice for v1: park Claude ACP permission gates only. The other three stay on the cold path, and stay exactly as correct as today.** A Pi approval, or a Claude client-tool approval, still destroys the session on pause and still resumes through today's decision-map replay. Those users get today's behavior, no better and no worse. The dispatch only takes the park-and-answer path when the parked gate is a Claude ACP permission id; any other gate shape on a parked session is treated as a mismatch and evicts to cold. Tests assert this. + +**What it would take to make the other gates parkable (the future path, not v1).** All three non-Claude gates are unparkable for the same root reason: their pending state does not live in the runner as an answerable handle after the turn ends. The two Pi gates keep it inside the sandbox on a bounded file poll, and the client-tool MCP pause destroys its request on purpose. Making any of them parkable means giving the runner a handle it can answer after the turn, the way the Claude ACP request already works. For the Pi gates, that means inverting the relay so the runner holds the pending call and the sandbox re-establishes it after resume, instead of polling a file that dies with the turn. For the client-tool pause, it means holding a resumable handle instead of destroying the socket, without letting the harness settle the call early. That is a relay and client-tool restructure, not a keep-alive change, so it is out of scope for v1. The full design, per gate, with the options and trade-offs, is in [followups/parkable-gates/design.md](followups/parkable-gates/design.md). The honest v1 statement: keep-alive makes the Claude approval path reliable now, and the follow-up extends the same reliability to Pi and to client tools. Until then, those users keep today's cold decision-map replay, with the same drift risk that path has always had. + +**What park mode has to change in the code, for the Claude ACP permission gate.** To be clear which gate this is: the Claude ACP permission gate (row 1 of the Part 1 table), the only gate keep-alive parks in v1. It is not the client-tool MCP pause (row 4), which is also a Claude gate but stays on the cold path in v1 and is covered by the parkable-gates follow-up above. For the ACP permission gate, park mode changes four things. On a park: do not fire the destroy callback (inject a park callback instead); do not abort the internal tool server (the environment stays alive); do not settle the paused call as "not executed" (it will actually run); and add a `resume()` that clears the paused tool-call ids so post-resume update frames stream again. The "a pause sends no harness reply" contract is unchanged; the reply just arrives later on the same session. The durable interaction row is created on pause as today and resolved on the decision as today, with one new ordering rule: a later turn's stale-interaction sweep must not cancel the interaction the runner is about to resolve on the same session. + +**Whose context runs the resumed tool.** The original turn's baked environment executes the tool: its signed mount credentials, resolved secrets, tool-callback auth, harness env, and sandbox. The resume request cannot re-bake any of that, because the harness process already holds it. The new turn owns only the egress side: its trace run records the resumed events, its emitter streams them, and its hooks resolve the interaction row. The credential epoch (Decision 7) bounds how stale the baked credentials may be. + +## Decision 7: the credential epoch + +**Problem.** A parked session baked its credentials at acquire time: the signed mount credentials, the resolved secrets, and the tool-callback auth. If it parks for the full approval TTL and one of those rotates or expires in the meantime, resuming would run the tool with stale credentials. + +**Why a plain identity check is not enough.** The wire carries raw secret values and no version identity. A secret could rotate to a new value under the same slug, and nothing on the wire would signal it. So the runner cannot detect a rotation by comparing an id or a version. + +**Choice.** The park record stores the mount-credential expiry timestamp plus a process-local hash over the actual resolved secret values and callback auth. That hash lives in runner memory only. It is never logged, never persisted, and never put in any emitted event or error. On the next request, if the epoch has expired or the incoming request's value hash differs, the runner treats it as a fingerprint mismatch: evict, destroy, cold-start with fresh credentials. The idle TTL (60 seconds) is far shorter than any credential lifetime, so this mostly matters for the longer approval TTL. + +## Decision 8: lifecycle and teardown safety + +**The config surface.** Four flags, added to the runner's config module rather than read ad hoc from `process.env`: +- `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off). +- `AGENTA_RUNNER_SESSION_TTL_MS` (recommended default 60000, the idle TTL). +- `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (recommended default 300000, the approval TTL; see Decision 9 for why 5 minutes and not 10). +- `AGENTA_RUNNER_SESSION_POOL_MAX` (recommended default about 8; see Decision 9 for the sizing math). + +With the flag off, behavior is byte-identical to today. Requests without a `session_id` never park. + +**Every teardown runs one idempotent `destroy()`.** This is the load-bearing safety property. Today teardown is guaranteed by a `finally`. Keep-alive defers teardown, so every trigger has to call the same complete, idempotent cleanup (the finalizers built up in `acquireEnvironment`, matching every step the old `finally` ran). The triggers are: + +- **Idle TTL expiry.** An idle session's timer fires; destroy. +- **Approval TTL expiry.** A parked-approval session's longer timer fires; destroy and abandon the held request. This degrades: the frontend still shows the Approve button, and the click lands on today's cold path. +- **LRU cap.** When the pool is full and a new session wants to park, evict the least-recently-used idle session. Never evict a busy or awaiting-approval session. If nothing idle can be evicted, do not park the new one; tear it down as today. Parking is best-effort. +- **Fingerprint mismatch.** Evict and destroy, run cold. +- **Explicit stop.** `POST /kill` drains the whole pool. +- **Runner shutdown (SIGTERM or SIGINT).** The shutdown handler drains the pool through `pool.destroyAll()`, timeout-bounded. This matters: the existing `inFlightSandboxes` registry alone is not enough, because its shutdown path only destroys the sandbox and skips the relay stop, the tool-server close, `destroySession`, dispose, unmount, and temp-dir removal. The pool's per-session `destroy()` is the authoritative cleanup. On a hard SIGKILL or OOM the process dies with its local children, and (for Daytona) the auto-stop backstop covers the remote sandbox a signal can never reach. +- **Client disconnect.** On abort, destroy, do not park. A session-owned run already survives a mid-turn disconnect, but a disconnect means the turn was abandoned, so there is no reason to hold the session after it ends. +- **Runtime failures.** A rejected parked prompt promise, a sandbox that died mid-idle (caught by a liveness probe on the next acquire), or a continuation that throws mid-turn all evict and fall back to cold; a mid-turn failure retries once cold. + +**The memory leak: does the pool help, hurt, or stay neutral.** There is a known leak class from the 2026-07-06 incident. Killing the daemon does not cascade the kill to the adapter subprocess it spawned; the orphaned adapter (and the harness under it) reparents to PID 1 and never exits. The fix was step 4 of teardown: send `session/cancel` before destroying the sandbox, so the adapter and harness shut down cleanly. That fix only runs on paths that reach `destroy()`. A hard SIGKILL or OOM of the runner skips it, and those local processes still leak. + +Honest answer on the pool: it is **neutral to slightly helpful on the graceful paths, and slightly worse on the hard-kill path**, and not materially either way. +- Helpful: the pool routes every graceful teardown (TTL, LRU, supersede, shutdown drain) through the same idempotent `destroy()`, so the `session/cancel`-before-`destroySandbox` fix keeps its coverage. The pool does not bypass it. +- Slightly worse: a pool keeps up to `POOL_MAX` trees alive at once instead of one. On a hard SIGKILL or OOM, all of them leak at once instead of just the in-flight one. The TTL reaper only runs while the runner lives, so it does nothing for a killed runner. +- Net: the pool neither introduces nor cures the root cause (the daemon not cascading its kill). It raises the count exposed to the uncatchable kill path. The real fix for that class is an OS-level backstop (a process-group kill or a reaper) that does not depend on the runner running its `finally`. That is recorded as a follow-up in status.md; it is not part of this design. + +## Decision 9: memory and CPU cost, measured, and how to size the pool + +These are measured numbers, not estimates. They were taken on the Hetzner dev box, inside the runner sidecar, against genuinely parked Claude sessions (method and raw figures in status.md). + +**Per parked Claude session (the three-process tree):** + +| Process | RSS | Pss (shared-adjusted) | +|---|---|---| +| sandbox-agent daemon (node) | ~16 MB | ~11 MB | +| ACP adapter (node) | ~82 MB | ~33 MB | +| Claude CLI (the harness) | ~246 MB | ~185 MB | +| **Total per session** | **~336 MB RSS** | **~224 MB Pss** | + +The harness process dominates. Pss (which counts shared library and binary pages once) is the honest marginal-cost figure, because the many node and claude processes share text pages; the true cost of each added session sits between the Pss sum and lower. + +**Idle CPU is near zero.** A parked session's processes block on I/O and use about 0% CPU. The quietest whole-container reading with sessions parked and nothing running was 0.45%. There is no CPU cost to keeping a session alive; the cost is memory. + +**Baseline (idle runner, no sessions):** about 250 MB RSS. + +**Sizing the pool.** With about 330 MB per parked Claude session, a full pool of 8 costs roughly 250 MB + 8 x 330 MB = about 2.9 GB RSS (less on a Pss basis). So the pool cap is really a RAM budget knob: pick it from the runner container's memory. Eight is a reasonable default for a container with a few GB to spare. + +**Is a cap of 8 too small, and what happens when the pool is full.** A small cap does not block anyone and does not fail any turn. When the pool is full and a new conversation wants to park, the runner evicts the least-recently-used idle session and parks the new one; a busy or awaiting-approval session is never evicted. If nothing idle can be evicted, the new session simply runs unparked (cold on its next turn), exactly as today. So the only thing a small cap costs is a lower cache hit rate: fewer conversations get the fast continue. It never sends a turn to an error, only to the cold path. Size the cap to the container's RAM, and raise it if the hit rate is low and RAM allows. + +## Decision 10: Daytona (cost, and where the difference lives) + +**Where the code difference lives.** The pool logic is provider-neutral. Local and Daytona runs use the same `SandboxAgent` and `Session` interface; only the provider construction differs (`local(...)` versus `daytona(...)` in `provider.ts`). Everything downstream (create, prompt, respondPermission, destroy, and the pool) is identical. So there is no Daytona-specific pool adapter. The pool holds opaque environments and one `destroy()` closure per session, and it does not care which provider built them. + +The one Daytona-specific reconciliation is the sandbox's own auto-stop. The runner explicitly sets a 15-minute idle auto-stop (`DEFAULT_DAYTONA_AUTOSTOP_MINUTES`, tunable through `DAYTONA_AUTOSTOP`) and creates the sandbox as ephemeral so it auto-deletes when it stops (`provider.ts`). This overrides the upstream default, which leaves auto-stop off, and it exists as a leak backstop: a sandbox the runner failed to tear down still stops and deletes itself. A parked session must not let the remote sandbox auto-stop out from under it. With a 5-minute approval TTL this is not a conflict (5 is well under 15). A longer TTL would need the auto-stop raised or disabled while parked, and the pool's own TTL becomes the primary reaper. That reconciliation lives in `provider.ts` and the TTL config, not in the pool. + +**Cost, and which sandbox size it assumes.** The runner does not size the Daytona sandbox. It passes no cpu, memory, or disk when it creates one, and there is no env override for size, so the sandbox comes up at the Daytona account's default spec. The runner does set a custom image (the `agenta-sandbox-pi` snapshot) and the 15-minute auto-stop above, but the resource size is the provider default. Daytona's default is 1 vCPU, 1 GiB RAM, 3 GiB disk, so the cost math below assumes that spec, and it would move if the account default moves. Daytona bills per second for the resources a sandbox consumes (about $0.0504 per vCPU-hour, $0.0162 per GiB-RAM-hour, and a negligible per-GiB-disk-hour). At the default spec a running sandbox costs about $0.067 per hour. Two facts drive the design: + +- A **stopped** sandbox bills only for disk (about $0.0003 per hour), roughly 200 times cheaper than running. So the cost of keep-alive on Daytona is the cost of keeping a sandbox running instead of stopping it. +- Cold start is sub-100 milliseconds, so cold-starting the next turn costs almost nothing in dollars; its only cost is latency. + +Back-of-envelope for a 5-minute idle TTL per conversation on the default sandbox size: about $0.0056 (half a cent) per conversation. At 100 active users running roughly 10 conversations a day, that is about $168 a month; at 1,000 users, about $1,680 a month. The ceiling, if a sandbox were somehow kept running 24/7 per user, is about $49 per user per month, which is why the auto-stop and a short TTL matter. The dominant cost lever is not the TTL length but making sure sandboxes actually stop rather than linger running. This is why Daytona keep-alive (slice 3) is gated behind local success, and why its TTL should be shorter than the local one. + +--- + +# Part 4: Relations to the other features + +## Relation to session resume, and the runner-restart consequence + +**Runner restart during a deployment.** A redeploy restarts the runner, which drains and destroys every parked session (SIGTERM path, Decision 8). The consequence is contained: every parked conversation loses its live memory, and its next message runs cold. No turn fails, nothing corrupts, no sandbox leaks (the drain runs the full teardown). The user notices one slower turn. Keep-alive is a cache, and a cache is allowed to be cold after a restart. + +**The long-term answer is session resume.** The companion design ([../harness-session-resume/plan.md](../harness-session-resume/plan.md), option 3) restores memory across restarts and long gaps by reloading the harness's own session files with `session/load`, with no idle process kept alive. The two features compose as a three-tier fallback on the same `session_id`: + +1. Live pool hit inside the TTL: continue the running session (keep-alive). Fastest, highest fidelity, costs idle RAM. +2. Pool miss but the harness session file exists: reload it (session resume). Full fidelity, costs a load per turn, zero idle RAM. +3. Both miss: cold replay of the flattened transcript (today's path). Always available, always correct. + +Build order is keep-alive first, then session resume: keep-alive is runner-only with no storage work and it removes the two production approval failures immediately, and it establishes the fingerprint and skip-flatten seams that session resume reuses. + +## Relation to the interactions plane (durable human-in-the-loop, the "state later" story) + +Today, an approval travels on the **messages plane**: the decision rides inside the conversation and takes effect when the frontend re-sends it. There is a second, newer **interactions plane** (a `session_interactions` table plus `/sessions/interactions` endpoints), described in [../approval-boundary/how-approvals-work.md](../approval-boundary/how-approvals-work.md). Its vision is durable approvals: a parked run leaves a pending record that anyone can answer later, from any surface, without a chat held open. The runner already writes rows to it (create on pause, resolve on decision) for committed revisions. + +How the two planes compose with keep-alive once both are real: + +- **Keep-alive is the fast, in-memory tier of the same idea.** It parks the live session for a short window so the answer, when it comes quickly, resumes the original call with no replay. The interaction row is the durable record of that same pending approval. +- **The interactions plane is the slow, durable tier.** When the answer comes after the approval TTL (hours later, from another surface), the live session is long gone. The answer settles the interaction row, and a resume replays the conversation with the decision available. That is the same settle-by-stored-decision mechanism the cold path already uses. +- **Together:** a pending approval leaves both a parked live session (fast lane, valid for the approval TTL) and a durable interaction row (slow lane, valid indefinitely). Whoever answers first wins: a quick click resumes the live call; a late answer settles the row and replays cold. The deferred piece is the resolver that reconciles the two, so an API-plane answer can feed back into a run. Keep-alive does not build that resolver; it makes the fast lane real and leaves the row untouched so the durable design can build on it. + +--- + +# Slices + +1. **Keep-alive across normal turns.** Local only, flag off by default, runner-only. The pool (project-scoped key, credential epoch), the acquire/run split with incremental finalizers, session-lifetime listeners demuxing into the current-turn sink, the dispatch wrapper, and the shared idempotent `destroy()` that the shutdown handler drains. About 350 to 500 lines. Medium risk (the teardown deferral and the turn demux). Flag off is byte-identical. +2. **Keep-alive across approval pauses, Claude ACP permission gates only.** Park mode in `pause.ts`, the `respondPermission` resume, and the interaction-resolve ordering. Pi relay gates, Pi builtin gates, and client-tool MCP pauses stay cold (Decision 6), asserted by tests. About 200 to 300 lines. Highest correctness value and highest correctness risk. +3. **Daytona.** Remove the `isDaytona` gate after slices 1 and 2 have run in real use. Reconcile the sandbox auto-stop with the approval TTL. Verify cookie-fetch session reuse and mounted-cwd survival across a park. Small code, real operational risk (billed idle time, remote liveness). diff --git a/docs/design/agent-workflows/projects/session-keepalive/context.md b/docs/design/agent-workflows/projects/session-keepalive/context.md new file mode 100644 index 0000000000..911e2ab64e --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/context.md @@ -0,0 +1,33 @@ +# Session keep-alive: context + +## Why this work exists + +The agent has no memory across turns. Every turn, normal or approval-resume, destroys the harness session and cold-starts a fresh one. The new session receives the whole conversation as one flattened text block, so the model never sees its own structured history. On an approval, a fresh model has to re-issue the approved tool call from that lossy text, and it drifts or restarts the task. Two real production turns failed this way. The full analysis is in [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md). + +Keep-alive is the "simple solution" from that report (Part 3, option 2). It keeps the session alive for a short TTL after a turn ends, so the next message continues the same live session with full native memory, and an approval holds its permission request open instead of destroying the session. + +## Goals + +- Keep the harness session alive for a TTL after a turn ends, keyed on the conversation `session_id`. +- Continue the live session on the next message when the config and history still match. +- Hold the pending permission request open across an approval so the original tool call runs with its exact original arguments. +- Fall back to today's cold replay whenever the window expires or anything does not match. Never fail a turn. +- Ship flag-gated and local-only first. Flag off means byte-identical behavior. + +## Non-goals + +- No wire, SDK, or frontend change. The `session_id` key already rides the wire. +- No storage change. Persisting harness session files across restarts is session resume (option 3), a separate feature. +- No multi-replica routing. A pool miss degrades to cold replay; affinity routing is future work. +- Not a replacement for session resume. The two compose: keep-alive is memory within the TTL window; session resume is memory across restarts and long gaps. + +## Background + +- The runner is a long-lived single-replica Node daemon on port 8765. Per-process in-memory state already exists (`inFlightSandboxes`, the replica id, the `owner:session` affinity keys). A `Map` pool is viable today. +- The `sandbox-agent` package (0.4.2) already supports repeated `prompt()` calls on one session, re-attachable `onEvent` and `onPermissionRequest` listeners (both return unsubscribe functions), and `respondPermission(id, reply)` callable at any later time. +- The full code-grounded research, including the exact line references and the design, is in [architecture-notes.md](architecture-notes.md). This plan builds on it by reference and does not repeat it. + +## Relation to the other features + +- **Cold-replay failure report** (option 1, text-replay fixes): a separate track that lands regardless. It reduces the damage of cold replay but cannot restore structure or thinking. +- **Harness session resume** ([../harness-session-resume/plan.md](../harness-session-resume/plan.md), option 3): the target architecture for memory across restarts. Build it after keep-alive. It reuses keep-alive's fingerprint and skip-flatten seams. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/README.md b/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/README.md new file mode 100644 index 0000000000..0a5baecb6e --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/README.md @@ -0,0 +1,19 @@ +# In-place reconfiguration of a live session + +The follow-up to keep-alive Decision 2. It answers one question: when the user edits the agent's configuration mid-conversation, can the runner reconfigure the live parked session in place, instead of evicting it and cold-starting a fresh one? + +Keep-alive v1 evicts and cold-starts on any config change (Decision 2, "option C"). That is the simplest correct thing for the first release. It is not the only thing possible. Much of the config can change on a running harness without tearing the process down: the model is already set live today, the instructions and skills are workspace files the harness can re-read, and the tool list is a dynamic MCP surface. This design lays out the in-place path per config dimension, and the rule for the mixed case where some dimensions can update live and others still need a respawn. + +## Files + +- [design.md](design.md): the full design. The mental model, the per-dimension classification table, an in-place update design for each important dimension, the partial-reconfiguration rule that splits the config fingerprint into a live-updatable set and a respawn-required set, and the honest risks. + +## Read first + +- [../../architecture-notes.md](../../architecture-notes.md) Decision 2 ("the two fingerprints") and its config-change subsection. This design is the deep dive behind that subsection. +- [../../architecture-notes.md](../../architecture-notes.md) Part 1 ("What one turn builds today"), for how config is baked at `createSession`. +- [../../../approval-boundary/how-approvals-work.md](../../../approval-boundary/how-approvals-work.md), for what the config contains (harness rules, per-tool and per-MCP permissions, skills, instructions). + +## Status + +Design only. It builds on keep-alive slices 1 and 2 and is sequenced after them. v1 keeps option C; nothing here changes v1. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/design.md b/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/design.md new file mode 100644 index 0000000000..aa66667ed7 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/design.md @@ -0,0 +1,185 @@ +# Reconfiguring a live session in place + +This is the deep dive behind keep-alive [Decision 2](../../architecture-notes.md), the config-change case. Read that decision first. It explains the two fingerprints and why a config change today evicts the parked session and cold-starts a fresh one ("option C"). This document answers the question option C left open: how do we reconfigure the live session in place instead, per config dimension, without tearing the process down and without falling back to cold replay. + +Everything below is verified against `services/runner/src` and the `sandbox-agent` 0.4.2 package types as of 2026-07-08. + +--- + +## The mental model + +You are talking to the agent. You change the model in the playground, then send the next message in the same chat. Today the runner throws the running agent away and builds a new one with the new model, and the new agent reads the whole prior conversation back as a flattened transcript. You wanted a smaller change: keep talking to the same agent, just with a different model from here on. Cloud chat products already do this. You switch the model mid-conversation and continue; you lose the prompt cache, but the conversation keeps going. + +"Reconfigure in place" means exactly that, for every config dimension the user can edit: apply the change to the still-running harness and keep the turn on the same live session, so the agent keeps its full native memory and no transcript is flattened. + +One cost is accepted, and it is the only one. Changing the model (or the system prompt, or the tool set) invalidates the provider's prompt cache, so the next turn pays full input-token cost for the prior context. That is unavoidable and it is what cloud does too. It is cheap relative to the alternative. + +Two costs are refused, because they are the whole reason keep-alive exists: + +- **Cold replay.** Destroying the live session and rebuilding a new process tree from scratch. This is what option C does. It loses the agent's native memory (its real tool results and its own thinking) and it is where the two production approval failures came from. +- **ACP-reformatting prior messages.** Flattening the conversation back into the wire format and feeding it to a fresh harness. A reconfigure must never touch the prior messages. It changes the setup, not the history. + +Why v1 still ships option C: it is correct, it is small, and a config change mid-conversation is not the common case. This design is the incremental follow-up that turns the common config edits (the model, the instructions, a skill, a tool) into a live update, and keeps option C only for the dimensions that genuinely cannot change without a respawn. + +--- + +## How config reaches the process today + +A recap of Part 1, focused on the delivery channel, because the channel decides how hard an in-place update is. The config reaches the harness through three different paths. + +**1. Session config options over ACP.** Some settings are not files at all. They are ACP config options the runner sets on the session after it opens. The clearest example: the model. The runner does not bake the model into the spawn. It calls `createSession(...)` and then `applyModel(session, request.model)`, which calls `session.setModel(wanted)` (`engines/sandbox_agent/model.ts:54`). If the harness rejects the id, `applyModel` reads the allowed set off the error and picks the closest match, else falls back to the harness default. So the model already travels on a live, re-callable session method. The same channel carries the harness mode and thought level (`session.setMode`, `session.setThoughtLevel`, and the generic `session.setConfigOption(configId, value)`), and `session.getConfigOptions()` reports what the running harness will actually accept. + +**2. Workspace files on the sandbox filesystem.** The instructions memory file (`CLAUDE.md` for Claude, `AGENTS.md` for every other harness), the skills directories (`./skills/`), and the Claude settings file (`.claude/settings.json`) are written into the run's working directory by `prepareWorkspace` (`engines/sandbox_agent/workspace.ts:112`) before `createSession`. They are plain files. The sandbox exposes a filesystem write API, so the runner can rewrite any of them at any time, on a live session. Whether the harness re-reads a rewritten file is a separate question, answered per dimension below. + +**3. The MCP server list at session init, plus the internal tool server.** External MCP servers are passed once, as `sessionInit.mcpServers`, into `createSession` (`sandbox_agent.ts:921`). Gateway and code tools are delivered as the tools of one internal MCP server the runner hosts and lists into that same array (`buildSessionMcpServers`). MCP is a live protocol: a server can change its tool list and emit `notifications/tools/list_changed`, and the client re-lists. So the tools inside a server are a dynamic surface; the set of servers connected at init is not. + +The `sandbox-agent` daemon also exposes config-writing endpoints that map onto these channels: `setMcpConfig({directory, mcpName}, config)`, `setSkillsConfig({directory, skillName}, config)`, and their delete variants. These write the daemon's own per-directory config; they do not, on their own, make a running harness re-read anything. They matter for the respawn path and for future work, noted where relevant. + +--- + +## Per-dimension classification + +For each config dimension: how it is delivered today, whether a live session can pick up a change without a new session, and how hard the in-place update is. + +| Dimension | Delivered today | Live pickup without a new session | Difficulty | +|---|---|---|---| +| Model | `session.setModel(...)` after `createSession` (`model.ts:54`) | Yes. Re-call `setModel` on the live session | Easy. The method exists and the runner already calls it | +| Harness mode, thought level | `session.setMode` / `setThoughtLevel` / `setConfigOption` | Yes, when the harness advertises the option (`getConfigOptions`) | Easy, where supported; a clean unsupported-error path where not | +| Provider, endpoint, deployment, credential mode | Baked into the harness process environment at spawn | No. The auth wiring is process env, fixed at launch | Hard. Respawn | +| System prompt, `AGENTS.md` / `CLAUDE.md` | Workspace file, written before `createSession` (`workspace.ts:112`) | Rewrite the file live; pickup depends on whether the harness re-reads it per turn | Medium. Easy to rewrite, uncertain re-read | +| Skills | Workspace dirs `./skills/` (`workspace.ts`), plus `setSkillsConfig` | Rewrite the dirs live; pickup depends on when the harness scans for skills | Medium | +| Tools, custom tools | Tools of the runner's internal MCP server, listed at init | Change what the internal server serves, emit `tools/list_changed` | Medium. Needs a list-changed refresh the runner does not send today | +| External MCP servers | `sessionInit.mcpServers` at `createSession` | Tool changes within a connected server: yes, via list-changed. Adding or removing a server: no | Mixed. Easy within a server, hard to add a server | +| Permissions, harness rules | `.claude/settings.json` (rules) plus `default_mode` | Mode via `setMode`; rule lists by rewriting the file, if the harness re-reads it | Medium. Mode easy, rule lists uncertain | +| Sandbox, harness type | The whole process tree, built at acquire | No. The tree is the session | Hard. Respawn | + +The pattern: channel 1 (session config options) is easy, channel 2 (files) is a rewrite plus an uncertain re-read, and channel 3 splits (tools within a server are dynamic, the server set is not). The auth wiring and the sandbox are the respawn floor. + +--- + +## The in-place designs + +Each dimension states the problem, the options, the trade-offs, the choice, and what would have to be added if the capability does not exist today. + +### Model and provider + +**Problem.** The user changes the model between messages. Today this flips the config fingerprint and evicts the session. + +**What exists.** The model is not baked at spawn. `applyModel` sets it through `session.setModel` after the session opens (`model.ts`), with a fallback that reads the harness's allowed set from the rejection error. So the in-place path is already written; it just runs once, at acquire, instead of again on a config change. + +**Options.** (a) Keep evicting on a model change. (b) On a model-only change, re-run `applyModel` on the parked session and continue. (c) Re-run `applyModel` and, if the harness rejects the new id outright, fall back to evict-and-cold. + +**Choice: (c).** Re-apply the model on the live session. If `setModel` succeeds, continue the turn on the same session with the new model. If the harness cannot switch to it (an `UnsupportedSessionValueError` with no close match), evict and cold-start, which is exactly option C for that one case. The user gets the model switch with native memory intact, at the cost of the prompt cache. + +**Provider, endpoint, deployment, credential mode.** These are not the model id; they are the auth and routing wiring, baked into the harness process environment at spawn. A live harness cannot re-read its own environment. Changing any of them stays on the respawn path. In practice a plain model switch inside one provider is the live case; switching provider is the respawn case. This matches cloud, where staying on one account and changing the model is live and re-authenticating is not. + +### System prompt and instructions (`AGENTS.md` / `CLAUDE.md`) + +**Problem.** The user edits the base instructions or the system prompt mid-conversation. + +**What exists.** The instructions file is written into the cwd before `createSession` (`workspace.ts:112`). The sandbox filesystem API lets the runner overwrite it on a live session at any time. The system prompt proper (distinct from the memory file) is harness-specific: Claude gets no system-prompt injection at all (the runner sets `systemPrompt` to undefined for non-Pi harnesses), and Pi gets one as `SYSTEM.md` / `APPEND_SYSTEM.md` files in a Pi agent directory reached through an environment variable fixed at daemon spawn (`pi-assets.ts`). So the Pi system prompt is not a live-updatable session option; it is env-anchored at spawn. + +**Options.** (a) Evict on any instructions change. (b) Rewrite the instructions file on the live sandbox and continue, relying on the harness re-reading it. (c) Rewrite the file and inject a short turn-level notice that the instructions changed, so the effect is immediate regardless of when the harness re-scans the file. + +**Trade-offs.** Option (b) is clean but leans on an assumption we have not proven: that Claude Code and Pi re-read the memory file on each prompt rather than only at session start. If they read it only once, the rewrite silently has no effect until the next cold start, which is worse than evicting (the user's edit is ignored). Option (c) removes the dependency on re-read timing by making the change part of the next prompt, but it edits the prompt, which brushes against the "never reformat prior messages" rule; a one-line system notice on the new turn is not a reprocessing of history, so it stays within the rule. + +**Choice: (b) if we confirm per-turn re-read, else (c).** The deciding fact is an empirical one about the harness, recorded as an open question below. The memory file (system prompt) proper always requires the respawn path when the harness fixes it at session start; only the file-backed instructions are a candidate for live rewrite. + +**What is missing.** Nothing in the runner or `sandbox-agent`; the filesystem write exists. What is missing is a confirmed answer on re-read semantics, and (for option c) a small turn-level notice injector in `runTurn`. + +### Skills + +**Problem.** The user adds or removes a skill mid-conversation. + +**What exists.** Skills are written as `./skills/` directories in the workspace (`workspace.ts`), and the daemon exposes `setSkillsConfig({directory, skillName}, config)`. Adding a skill is writing a directory; removing one is deleting it. + +**Options.** (a) Evict on any skill change. (b) Write or delete the skill directory on the live sandbox and continue, relying on the harness discovering skills per turn. (c) Write the directory and signal the harness to re-scan. + +**Trade-offs.** Same shape as instructions. Claude discovers skills from the skills directory; the open question is whether it re-scans that directory on each prompt or caches the catalog at session start. Adding a skill that the harness never re-scans is a silent no-op until the next cold start. Removing a skill is safer to defer (a still-listed skill the user removed is a smaller harm than a missing one they added), but for consistency both follow the same rule. + +**Choice: (b) if per-turn discovery is confirmed, else respawn for skill changes.** Skills are lower-frequency edits than the model, so falling back to respawn here costs little while the re-scan question is open. If a re-scan signal turns out to exist over ACP, prefer (c). + +**What is missing.** A confirmed re-scan behavior, and possibly an ACP signal to force a skills re-scan. If neither, skill changes stay on the respawn path with no loss relative to today. + +### Tools and the internal tool server + +**Problem.** The user adds, removes, or edits a gateway or code tool mid-conversation. + +**What exists.** These tools are the tools of one internal MCP server the runner hosts (`buildSessionMcpServers`); the harness sees them as `mcp__agenta-tools__`. MCP is a live protocol. A server may change its advertised tools and send `notifications/tools/list_changed`; a compliant client re-lists. + +**Options.** (a) Evict on any tool change. (b) Rebuild the internal server's tool set in place and emit `tools/list_changed` so the harness re-lists. (c) Same as (b) but also re-render the Claude settings allow/deny rules for the changed tools (see permissions below), since a new tool with no allow rule would otherwise stop at the Claude gate. + +**Trade-offs.** The internal server is the runner's own code, so changing what it serves is in reach. The cost is that a live tool-set change is two coordinated steps: the served set and (for Claude) the permission rules that pre-answer those tools. Missing the second step means a newly added `allow` tool prompts for approval it should not, or a removed tool lingers as a rule. Option (c) keeps the two in sync. + +**Choice: (c).** Rebuild the internal tool set, emit `tools/list_changed`, and re-render the tool permission rules together, on the live session. + +**What is missing.** The runner does not send `tools/list_changed` today; the internal server is built once at acquire. This is net-new runner work: make the internal server's tool set mutable and wire a refresh that both re-lists and re-renders rules. It also depends on the harness honoring `list_changed` (Claude does; confirm per harness). + +### External MCP servers + +**Problem.** The user changes the MCP server configuration. + +Two sub-cases, and they split. + +**Tools within an already-connected server change.** The server itself emits `tools/list_changed`; the harness re-lists. This is live and needs nothing from the runner, because the server, not the runner, drives it. + +**A server is added or removed.** MCP servers are passed once as `sessionInit.mcpServers` and connected at session init. A running harness has already established (or not) its connections. `setMcpConfig` writes the daemon's config file but does not make a live harness dial a new server. Adding or removing a server therefore stays on the respawn path. + +**Choice.** Live for tool changes within a connected server (free). Respawn for adding or removing a server. If a future ACP or harness capability lets a live session attach or detach an MCP server, the add/remove case moves to live too; that is a harness capability we do not have today. + +### Permissions and harness rules + +**Problem.** The user changes the permission policy, a per-tool permission, or the Claude rules. + +**What exists, split by kind.** The Claude top-level mode (`default_mode`: `default`, `acceptEdits`, `plan`, `bypassPermissions`) is an ACP session config, settable live via `session.setMode`. The allow/ask/deny rule lists live in `.claude/settings.json`, a workspace file rendered by the SDK. + +**Options.** (a) Evict on any permission change. (b) Set the mode live via `setMode`, rewrite the rule file live, and continue. (c) Mode live via `setMode`; rule-list changes stay on respawn until re-read is confirmed. + +**Trade-offs.** The mode is clean: it is a real ACP config option. The rule lists are a file, so they inherit the same re-read uncertainty as the instructions. There is a sharper reason to be careful here than with instructions: getting a permission change wrong is a security-relevant miss. A rewritten `deny` that the harness does not re-read means a tool the user just forbade still runs. So the file-backed rule lists must fail safe. + +**Choice: (c), fail-safe.** Apply mode changes live. For rule-list changes, evict and cold-start unless per-turn re-read is confirmed, because a silently-ignored `deny` is worse than a cold turn. This is the one dimension where we prefer respawn to an uncertain live update on principle, not just on frequency. + +### The respawn floor: sandbox and harness type + +Changing the harness (Claude to Pi) or the sandbox means a different process tree. The tree is the session. There is no in-place path and there should not be one; these always take the respawn path (option C). They are the floor the partial-reconfiguration rule builds on. + +--- + +## Partial reconfiguration: the mixed case + +**Problem.** A single edit can touch several dimensions at once. The user changes the model (live-updatable) and adds an external MCP server (respawn-required) before sending the next message. Some of that change can apply to the live session; some cannot. + +**The rule.** Update in place what can be updated live. Fall back to evict-and-cold only for the dimensions that truly require a respawn. Concretely: if every changed dimension is in the live-updatable set, reconfigure the parked session in place and continue on it. If any changed dimension is in the respawn-required set, evict and cold-start with the whole new config, exactly as option C does today. A respawn already carries the new config for every dimension, so the live-updatable ones ride along for free; there is no partial respawn. + +**How the fingerprint splits.** Decision 2 hashes all config-bearing fields into one config fingerprint, and any difference evicts. This design splits that one hash into two: + +- **The live-updatable fingerprint.** Model, harness mode, thought level, instructions file, skills, internal tool set, tool permission rules that re-render with the tools, and MCP tool-list changes within a connected server. A difference here triggers an in-place reconfigure, not an eviction. +- **The respawn-required fingerprint.** Harness type, sandbox, provider, endpoint, deployment, credential mode, the added or removed set of external MCP servers, and (until re-read is confirmed) the file-backed permission rule lists and the system prompt proper. A difference here evicts and cold-starts. + +On each request the runner compares both. Respawn-required differs: cold path. Only live-updatable differs: reconfigure in place, then continue. Neither differs: continue with no reconfigure, exactly keep-alive's normal hit. The history fingerprint (Decision 2) is unchanged and still gates every continuation; a reconfigure changes the setup, never the history. + +**Worked example.** The parked session runs model A with three tools. The user switches to model B and edits one tool's description, then sends "keep going." + +- Today (option C): the config fingerprint differs, so the session is evicted and a fresh tree cold-starts with model B and the edited tool, replaying the whole transcript. +- With this design: both changes are in the live-updatable set. The runner calls `setModel(B)` on the parked session, rebuilds the internal tool server's set and emits `tools/list_changed`, re-renders the tool's permission rule, then calls `session.prompt("keep going")`. The agent keeps its native memory of the first three turns. The only cost is the lost prompt cache. If the same edit had also added a new external MCP server, the respawn-required fingerprint would differ and the whole thing would fall back to option C. + +**Where the reconfigure step lives.** Keep-alive splits the turn into `acquireEnvironment` and `runTurn` (Decision 4). The reconfigure is a new third step between them, `reconfigureEnvironment(env, request)`, run only on a live-updatable-only difference. It applies each live update through the channel that dimension uses (a session config call, a filesystem write plus a re-read signal, or an internal-server rebuild plus `list_changed`), returns the updated fingerprints for the park record, and then the turn runs as a normal continuation. If any single live update fails at runtime (a `setModel` rejection, a filesystem write error), the step aborts to eviction and cold-start, so a reconfigure bug can only cost a cold turn, never a wrong setup. This mirrors keep-alive's universal-fallback rule. + +--- + +## Consistency with keep-alive, and ordering + +Keep-alive v1 keeps option C. Any config change evicts and cold-starts. This design changes nothing in v1; it is the incremental follow-up that adds the live path later. Nothing here is required for slices 1 through 3 to ship. + +Ordering: after keep-alive slice 1 (normal-turn continuation) and slice 2 (approval parking), which establish the pool, the acquire/run split, the fingerprints, and the parked-session lifecycle this design extends. A sensible first increment is the model alone, since the mechanism (`applyModel` on the live session) already exists and only the dispatch has to route a model-only change to a re-apply instead of an eviction. Instructions, skills, tools, and permissions follow, each behind its own confirmation of the harness's re-read behavior. + +--- + +## Risks and open questions + +- **Does the harness re-read workspace files per turn?** The load-bearing unknown. If Claude Code and Pi re-read the memory file, the skills directory, and the settings file on each prompt, the file-backed dimensions become live with a plain rewrite. If they read them only at session start, those dimensions stay on the respawn path (or need a turn-level notice for instructions, and stay fail-safe for permissions). This must be measured against each harness before building the file-backed updates; it is not derivable from the runner code. +- **Prompt-cache loss is real and accepted.** Every live reconfigure that changes the model, the system context, or the tool set invalidates the provider's prompt cache, so the next turn pays full input cost for the accumulated context. This is the accepted cost and matches cloud behavior. It should be visible in traces so a cost regression is attributable. +- **`tools/list_changed` refresh does not exist yet.** The internal tool server is built once at acquire. Making its tool set mutable and emitting `list_changed` mid-session is net-new runner work, and it depends on each harness honoring the notification (Claude does; confirm per harness). +- **Permission changes must fail safe.** A silently-ignored `deny` is a security-relevant miss, not just a stale setting. Until per-turn re-read of the rule file is confirmed, rule-list changes stay on the respawn path on purpose, even though the mode is settable live. +- **Adding an external MCP server needs a harness capability we do not have.** Tool changes within a connected server are live for free; attaching or detaching a server on a running session is not exposed by ACP or the harnesses today, so it stays on the respawn path until it is. +- **`setConfigOption` support is per-harness and discoverable, not guaranteed.** `session.getConfigOptions()` reports what the running harness accepts. The live path for mode and thought level must key off that report and fall back cleanly on `UnsupportedSessionConfigOptionError`, rather than assuming every harness accepts every option. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md new file mode 100644 index 0000000000..028b3d3258 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md @@ -0,0 +1,53 @@ +# Parkable gates: making Pi and client-tool approvals survive a turn + +The design in this folder is judged by one invariant: whether a human answers an approval in +ten seconds or overnight, the sequence of LLM API calls should be identical. Call N ends with +the model returning a `tool_use`; call N+1 appends the real `tool_result` for that same id; +nothing is regenerated in between. + +Keep-alive slice 2 gives one gate that property: the Claude ACP permission gate. Its parked +session still holds an answerable handle after the turn ends, so a click resumes the exact same +live tool call. The other three gates (the Pi custom-tool relay gate, the Pi builtin gate, and +the client-tool MCP pause) do not have that handle, so they fall to cold replay, where the +model re-issues a new call and the arguments can drift. + +The 2026-07-09 kill-and-resume experiments +([report](../../../harness-session-resume/experiments/report.md)) proved that warm parking is +the ONLY tier that meets the invariant: the pending call survives a hard kill on disk in both +harnesses, but neither harness will answer it on load; both settle it as errored and re-issue. +So harness session resume gives faithful continuation, not exact resumption, and this folder's +work (extending the warm park to the remaining gates) cannot be replaced by it. + +For the two Pi gates the mechanism is decided and proven live: Option C, routing the approval +over the ACP permission plane the `pi-acp` bridge already has (`ctx.ui.confirm` at the gate, a +JSON envelope carrying the real tool identity, the runner's slice-2 park machinery holding the +request). The [spike-option-c report](spike-option-c/report.md) has the evidence, including a +three-minute held gate that resumed the original call with its original arguments. + +## Who should read this + +- Anyone extending keep-alive past slice 2. +- Anyone working on the backend warm-session move or harness session resume; this design must + land on top of (or inside) that work, and the two are tiers of the same invariant. +- Anyone touching the Pi tool relay (`services/runner/src/tools/relay.ts`, + `services/runner/src/tools/dispatch.ts`) or the internal tool MCP server + (`services/runner/src/tools/tool-mcp-http.ts`). + +## Read first + +- [architecture-notes.md, Decision 6](../../architecture-notes.md) ("the approval win, for + Claude today and Pi later"). This document is the deep dive behind that decision's future + path paragraph. +- [how-approvals-work.md](../../../approval-boundary/how-approvals-work.md), the full gate + model, the three gates, and the two planes (messages and interactions). +- [The experiments](../../../harness-session-resume/experiments/): the protocol and report for + the kill-and-resume runs that settled what each tier can guarantee. + +## Files + +- [design.md](design.md): the full design. The invariant, the measured tier ranking, each of + the three gates (how it pauses today, why it is not parkable, the options, the choice), the + per-gate cold paths, how the result composes with keep-alive, session resume, and the + interactions plane, ownership and ordering, and risks. +- [spike-option-c/](spike-option-c/): the live spike that proved Option C. Protocol, report, + the spike extension, the ACP client, and the raw wire transcripts. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/design.md b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/design.md new file mode 100644 index 0000000000..9b46f63243 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/design.md @@ -0,0 +1,573 @@ +# Parkable gates: one invariant, four gates, and why warm parking is the only exact tier + +Keep-alive slice 2 made one thing true: a Claude approval survives the turn it paused on. The +human clicks Approve minutes later, and the runner answers the exact tool call that was open, +with its exact original arguments. No new agent re-issues anything, so nothing drifts. + +This document extends that property to the three gates that still lack it: the Pi custom-tool +relay gate, the Pi builtin gate, and the client-tool MCP pause. It is judged by one invariant, +stated first, because the invariant (not connection plumbing) is what decides every choice below. + +Everything below is verified against `services/runner/src` as of 2026-07-08, against the +kill-and-resume experiments of 2026-07-09 +([protocol](../../../harness-session-resume/experiments/protocol.md), +[report](../../../harness-session-resume/experiments/report.md)), and against the live Option C +spike of the same day ([spike-option-c/report.md](spike-option-c/report.md)). + +--- + +## The invariant this design is judged by + +At the LLM layer, an approval is simple. The model API is stateless. Call N ends with the model +returning a `tool_use` block. Call N+1 sends the same message list plus the real `tool_result` +for that block. The invariant this design wants: + +> Whether the human answers in ten seconds or after twelve hours, the sequence of LLM API calls +> is identical. Call N ends with `tool_use`. Call N+1 appends the real `tool_result` for that +> same id. Nothing is regenerated in between. + +The LLM never waits, so the invariant is achievable in principle: whoever holds the exact +message list and the pending call's identity can answer it at any later time. What breaks the +invariant today is harness plumbing. When a gate pauses a turn, the runner destroys the session +and later replays a flattened text transcript into a fresh agent. That fresh agent makes a new +LLM call, and the model generates a *new* `tool_use` with a new id and possibly different +arguments. Both production approval failures came from exactly that drift. + +"Parking" is the mechanism that preserves the invariant: keep the harness process alive (it +holds the real message list in memory) and keep an answerable handle to the blocked call. The +question this document answers per gate is whether such a handle can exist. + +## What each tier can actually guarantee (measured, not assumed) + +Could a cold restart meet the invariant instead, by resuming the harness's own session file? +The kill-and-resume experiments settled this. Both harnesses were examined mid-gate: blocked on +a permission, pending `tool_use` issued, no result yet. Claude Code was killed and resumed live +on both its paths (CLI `--resume`, and ACP `session/load`, the path the runner ships on, with +the resume id in `_meta.claudeCode.options.resume`); Pi was verified from source. Two findings: + +- **The pending `tool_use` survives a hard kill on disk, on both harnesses.** Both flush the + session file per message, so the assistant message carrying the pending call is durable before + the gate resolves. Claude's JSONL held it mid-block and was byte-identical after `kill -9`. + Pi persists on `message_end`, which fires before its permission gate runs. +- **Neither harness will ever answer that surviving call.** On load, the parked call is + force-settled: Claude ACP records a synthetic error `tool_result` and marks the call failed; + Claude CLI abandons it as a no-op; Pi injects a synthetic "No result provided" error result at + the LLM boundary. In every case the model then re-issues a NEW `tool_use` (new id, regenerated + arguments) to do the work. A logging proxy on the wire confirmed the call sequences differ. + +So the tiers rank like this, and only the first is exact: + +| Tier | Mechanism | What it guarantees | Bound | +|---|---|---|---| +| 1. Warm park | The harness process stays alive; the runner holds an answerable handle | The invariant, byte-exact: the original call runs with its original arguments | The approval TTL (default 5 minutes) | +| 2. Harness session resume | `session/load` rehydrates the harness's own session file after the process died | Faithful continuation: full structured history, but the parked call is settled as errored and the model re-issues a new one. Small drift risk, bounded because the original request text is still in context | Whenever the file survives | +| 3. Cold replay + durable decision | Flattened text into a fresh agent; the stored decision is matched by tool name plus canonical arguments | Correct outcome when the re-issued arguments match; the gate re-fires when they drift | Always available | + +This ranking is the reason this design exists. Warm parking is not a latency optimization; it +is the only tier that meets the invariant. Session resume (which makes tier 2 real) does not +replace it and cannot, by harness policy rather than by lost state. That makes extending +parking to the remaining gates worth real gate changes, and it makes the durable-decision +cold path the permanent answer for anything past the warm TTL, such as an overnight approval. + +--- + +## The parkability property, and where each gate holds its pending state today + +A gate is **parkable** if, after the turn ends, the runner still holds a handle it can answer +to make the original tool call proceed. The crux is one question: **where does the pending wait +live?** If it lives in the runner's own memory, the runner can answer it after the turn ends. +If it lives inside the sandbox, or the runner destroyed it on pause, there is nothing to +answer. + +| Gate | Harness | Where the pending wait lives today | Parkable? | +|---|---|---|---| +| ACP permission gate | Claude | A pending request in the runner's `pendingPermissionRequests` map, plus a suspended `prompt()` whose HTTP connection is held open by a disabled undici timeout (`acp-fetch.ts`, `headersTimeout: 0`). | Yes (slice 2). | +| Custom-tool relay gate | Pi | A file poll **inside the sandbox**. `relayToolCall` busy-polls for a response file until a deadline (`dispatch.ts:88-110`). The runner holds nothing. | No. | +| Builtin gate | Pi | The same file poll, one level in: a Pi `tool_call` hook blocks on `relayPermissionCheck` (`agenta.ts:181-207`, `dispatch.ts:176-213`). | No. | +| Client-tool MCP pause | Claude (client tools) | Nowhere. The runner **destroys** the in-flight HTTP request (`tool-mcp-http.ts:284-287`, `res.destroy()` with no body). | No. | + +The Claude ACP gate is parkable because its wait is a runner-held promise and the harness +process is kept alive by keep-alive. The other three lack exactly that. The rest of this +document is about giving it to them. + +The shared fact worth stating once: raising a timeout never makes a gate parkable. A longer +bounded wait is still a bounded wait that dies when the turn ends. Parkability is a question of +*who holds the handle*, not *how long the wait is*. + +--- + +## Gate 1: the Pi custom-tool relay gate + +### How it pauses today + +Pi cannot reach Agenta from inside the sandbox, so a Pi tool call is relayed through files. When +Pi calls a custom tool, its `execute` callback runs `runResolvedTool`, which calls +`relayToolCall` (`dispatch.ts:66-111`). That function writes `.req.json` into the relay +directory and then busy-polls for `.res.json`: + +``` +deadline = now + (spec.timeoutMs + 10s, or RELAY_TIMEOUT_MS) // dispatch.ts:88-90 +while (now < deadline) { + if (response file exists) return its text + sleep(RELAY_POLL_MS) // 300 ms +} +throw new Error("tool relay timed out for ") // dispatch.ts:110 +``` + +`RELAY_TIMEOUT_MS` defaults to 60000 and is set by `AGENTA_AGENT_TOOLS_RELAY_TIMEOUT` +(`relay.ts:61-63`). On the runner side, `startToolRelay` (`relay.ts:449`) watches the same +directory and writes the response file. + +For a tool marked `ask`, the sequence is faster and harsher than a timeout. The relay watcher +sees the request, emits the approval event, records the durable pending interaction, writes no +response, and fires the pause controller (`relay.ts:255-263`, `sandbox_agent.ts:1272-1293`). +The pause controller ends the turn `paused` and immediately destroys the Pi session +(`sandbox_agent.ts:1135-1157`): its keep-alive exemption keeps a session alive only when +`env.parkedApproval` was recorded, and the only code that records it is the Claude ACP +permission hook (`sandbox_agent.ts:1321-1338`). So the in-sandbox poll never reaches its +60-second deadline on an ask; it dies of teardown, moments after the ask is recorded. The +deadline matters only as a backstop on calls where nothing pauses (a slow callback response). + +### Why keep-alive does not park it today + +The natural question: within the warm window, why not simply hold the request open until the +response arrives, the same behavior the Claude ACP gate has, and go cold only when the session +does? That is exactly the right end state, and nothing structural prevents it. The runner +already holds everything a handle needs (the sandbox, the relay directory, the tool-call id, +the recorded interaction), and the in-sandbox poll would keep waiting as long as the sandbox +lives. The reason it does not work today is not the timeout; it is that three specific pieces +of code are Claude-shaped: + +1. **The park decision destroys Pi first.** The pause path keeps a session alive only when the + Claude hook recorded `parkedApproval`; a Pi ask never records one, so the session (and the + poll inside it) is destroyed the moment the pause fires (`sandbox_agent.ts:1145-1157`). + Raising `RELAY_TIMEOUT_MS` to match the warm TTL changes nothing here: the poll does not + die of its deadline, it dies of this teardown. +2. **The resume has no write-the-file action.** When the approval arrives, the dispatch's only + live-resume verb is `session.respondPermission` (`server.ts`), which answers a + harness-raised ACP request. No code path maps "human approved tool-call X" to "execute the + callback and write X's response file into the parked sandbox." +3. **The poll deadline is shorter than the park window.** Once 1 and 2 exist, the in-sandbox + deadline must cover the approval TTL on keep-alive runs (and stay bounded, fail-closed, + everywhere else). + +These are deltas to the existing mechanism, not a redesign. Option B below is precisely these +three deltas and nothing more. + +### Options + +**Option A: raise or remove `RELAY_TIMEOUT_MS` and change nothing else.** Give the in-sandbox +poll a deadline that matches the warm window, so it does not give up while a human deliberates. + +- Trade-off: the timeout is not what kills the wait; the destroy-on-pause is (piece 1 above). + A poll with a week-long deadline still dies moments after the ask, when the pause controller + destroys the session. And on any run that cannot park (keep-alive off, pool full), a long or + unbounded deadline would block Pi's `prompt()` with no one ever coming to answer, which is + the run-hang failure the approval model already learned to avoid. A bigger number, alone, + fixes nothing and adds a hang risk. Rejected as a standalone change; the deadline change is + real but it is piece 3 of Option B, valid only alongside pieces 1 and 2. + +**Option B: park the relay wait: keep the session alive, write the response file on approval.** +This is the hold-until-answered behavior stated plainly, built as the three deltas from "why +keep-alive does not park it today": + +1. The pause path records a Pi park (the analog of `parkedApproval`: gate type, tool-call id, + the relay directory) instead of destroying the session, under the same keep-alive park-mode + conditions the Claude gate uses. +2. The approval-resume dispatch, when the parked gate is a Pi relay gate, executes the approved + callback and **writes the response file into the still-living sandbox** (a deny writes a + deny), instead of calling `respondPermission`. The blocked `execute` callback reads it and + returns its real result, so Pi continues the original call with its original arguments. No + new agent re-issues anything. +3. The in-sandbox deadline covers the park window on keep-alive runs (approval TTL plus + margin), and keeps today's bounded fail-closed behavior everywhere else. + +To be precise about what "turn" means here, because it is where the mechanism is easy to +misread: no new prompt is ever sent to Pi on this path. Pi's original `prompt()` stays in +flight the whole time, suspended inside the blocked `execute`. The "new turn" is only the +transport of the human's decision: the frontend delivers the approval as a new `/run` request, +and that request's stream adopts the resumed events, exactly as the Claude resume already does. +Pi itself sees one uninterrupted turn. Only when the park is gone (TTL expired, session died) +does the decision arrive at a genuinely new agent turn, the cold path below. + +- Trade-off: the changes live in three places (pause path, resume dispatch, poll deadline) + rather than one, and the egress must emit a `paused` stop while Pi's `prompt()` is genuinely + still in flight inside a blocked tool call; the Claude path ends a turn while holding an + out-of-band request, whereas here the turn ends while an in-band call is suspended. That + ordering is the novel part and the main risk. In return, everything stays in our code, on the + transport that already exists, with no new dependency. + +**Option C (proven live, recommended): route Pi approvals over the ACP permission plane the +bridge already has.** Stop expressing Pi approvals through the relay files at all; raise them +as real ACP permission requests the runner holds as promises, exactly like Claude's gate. The +relay files stay for tool execution only. A live spike proved the mechanism end to end on +2026-07-09: [spike-option-c/report.md](spike-option-c/report.md) (protocol, the spike +extension, the ACP client, and raw wire transcripts sit alongside it). + +An earlier draft dismissed this as upstream work inside Pi. That was wrong on both counts: + +- **Pi already has the hooks.** Every Pi extension event handler receives a `ctx.ui` with + `confirm(title, message): Promise` plus `select`/`input`/`notify` + (`pi-coding-agent/dist/core/extensions/types.d.ts:208-214, 804`), and dialog UI is available + in RPC mode, the mode the bridge runs Pi in. Our in-sandbox extension's `tool_call` hook and + a custom tool's `execute` can await a dialog the same way they await the file poll today. +- **The bridge already translates that dialog into a real ACP permission request.** `pi-acp` + maps an extension `confirm` event to `session/request_permission` with yes/no options and + feeds the answer back to the blocked `ctx.ui.confirm` + (`pi-acp/dist/index.js:1106-1128`, `handleExtensionConfirm` -> `conn.requestPermission`). + The `permissions: false` note in how-approvals-work.md is about Pi's own tool executions + never consulting the client; the extension-UI dialog path does consult it, today. +- **So the missing piece is neither Pi nor the protocol.** If our extension asked + `ctx.ui.confirm` at the gate instead of polling the relay, the runner would receive the same + parkable, answerable ACP request it gets from Claude, and slice 2's park machinery (hold the + request, park the session, `respondPermission` on resume) would apply with a new gate type + and almost no new mechanism. + +What the 2026-07-09 spike settled, driving a real Pi session under the real bridge (five +scenarios: allow, deny, three-minute hold, transport drop, clean reject): + +- **The hop is real, mid-gate.** `ctx.ui.confirm` raised from inside a `tool_call` hook + arrives at the ACP client as a genuine `session/request_permission` while the tool call sits + pending, and the answer resolves the blocked hook (report, Q1). +- **The park is the default, not an engineering feat.** The held request survived 180067 ms + with no reaper, and the late allow ran the original call with its original arguments inside + one uninterrupted `prompt()`. Pi's dialog helper arms a timeout only when the caller passes + one; the hook passes none, and the relay's `RELAY_TIMEOUT_MS` is never touched on this path + (report, Q3). The unbounded fail-closed wait Option B must engineer into the relay is this + path's out-of-the-box behavior. +- **Fail-closed holds on every unhappy path.** A deny and a cleanly rejected ACP request both + resolve the dialog to false, the hook blocks, and the tool never runs; a transport drop + kills the bridge and Pi cleanly and degrades to tier-2 session resume (report, Q4). +- **The payload gap closes with an envelope, not upstream work.** Natively the bridge forwards + only the dialog strings (`toolCallId` `pi-ui-`, `rawInput` `{method, title, message}`, + `index.js:565, 1130-1144`). A JSON envelope carrying the real gate identity (tool name, call + id, arguments) through the message field round-trips byte-exact, including quotes, + backslashes, Japanese text, and newlines (report, Q2). +- **The sandbox-agent path forwards it unchanged** (`acp-http-client@0.4.2` passes + `requestPermission` straight through, fail-closed when no handler is attached), and the + runner's permission responder routes a spec-less gate onto the slice-2 park machinery + (report, Q5, source-verified). One live daemon run remains as a confidence check; it is a + residual, not an open question. + +Shipping C is therefore two runner work items, not a mechanism build: + +1. **Parse the envelope into the real gate identity** where permission requests are + classified, so approval cards, the durable decision map, and permission policy key on the + actual tool name, call id, and arguments rather than on `agenta-approval` dialog strings. +2. **Switch the in-sandbox extension** to raise `ctx.ui.confirm` (with the envelope) at both + gates instead of the relay poll; the relay keeps carrying execution. + +Ownership stays worth naming: Pi (`@earendil-works/pi-coding-agent`) is Mario Zechner's; +`pi-acp` is a separate MIT adapter by Sergii Kozak (`svkozak/pi-acp`, 0.0.29 bundled). The +cleanup end state is a small upstream `pi-acp` field for structured metadata (or a pnpm patch, +which this repo already uses for `sandbox-agent`), retiring the envelope encoding. + +- Trade-off: C reuses the proven Claude park machinery unchanged (one gate mechanism for both + harnesses, no relay-timeout surgery, no write-file resume verb) and removes the "pause + expressed as a missing file" awkwardness. Against that, it puts a correctness-critical path + through a third-party bridge, carries the envelope encoding until an upstream field exists, + and shares Option B's novel ordering (the turn still ends while Pi's `prompt()` hangs inside + a blocked hook). + +### Recommended option + +**Option C, proven, shipping with the JSON envelope.** The spike removed the condition the +earlier recommendation hinged on: the hop works, the payload survives by encoding, and the +park behavior needed is the dialog path's default. C is the smallest delta from slice 2 (the +runner holds and answers the same ACP request shape it already parks for Claude) and gives +both harnesses one gate mechanism. Option B stays documented above as the fallback, and it is +explicitly not chosen: it needs three code deltas plus a new write-the-file resume verb to +build, inside our own relay, the exact wait the dialog path provides for free, and its only +advantage (no third-party bridge in the path) is not worth that build while the bridge is +pinned and patchable. If the residual daemon run or the Pi upgrade path ever breaks the dialog +channel, B is the recorded escape route. Either way the gate degrades cleanly: with keep-alive +off, or on any run that cannot park, the bounded fail-closed behavior of today remains. + +Before/after, one Pi approval, against the invariant: + +- Today (tier 3): Pi calls a gated tool. The runner records a durable interaction, writes no + response, ends the turn `paused`, and destroys the session (the poll dies with it). The human + clicks Approve. A fresh Pi session cold-replays the transcript, and the model re-issues the + call from text as a NEW `tool_use`; the runner matches it against the stored decision by name + plus canonical arguments. If the regenerated arguments drift, the gate re-fires. The LLM call + sequence differs from the warm one. +- With Option C (tier 1, inside the approval TTL): Pi calls a gated tool. The extension's gate + raises `ctx.ui.confirm` with the envelope; the bridge surfaces it as a real + `session/request_permission`; the runner parses the envelope, records the park, emits + `paused`, and keep-alive holds the live session with the request open. The human clicks + Approve. The runner answers the held request (`respondPermission`); the blocked gate returns + allow; the original call runs with its original arguments, and call N+1 to the LLM carries + the real `tool_result` for the original id. The invariant holds. + +## Gate 2: the Pi builtin gate + +### How it pauses today + +Pi's builtins (bash, read, write) are not relayed for execution; they run inside Pi. To gate +them, a Pi `tool_call` hook (`agenta.ts:181-207`) runs before the builtin and blocks on +`relayPermissionCheck` (`dispatch.ts:131-213`). That function writes a permission request into +the same relay directory and polls for a permission response, on the same `RELAY_TIMEOUT_MS` +deadline (`dispatch.ts:176`). It is fail-closed by construction: the comment at `dispatch.ts:129` +states it "must fail closed because returning nothing lets Pi execute the builtin." On timeout or +any unparseable answer it returns a deny, so an unanswered gate blocks the builtin rather than +letting it run. + +On an ask, the runner side does not stay silent the way it does for Gate 1. The watcher answers +the permission request immediately with `verdict: "pendingApproval"` (`relay.ts:433-446`) and +fires the same pause; the hook has no third outcome to express that with, so it maps anything +but an allow to a `blockReason`, a deny (`agenta.ts:199-200`). The pause then destroys the +session the same way as Gate 1. So today an ask on a builtin is answered instantly, as a +blocked call, and the turn ends `paused` with the session gone. + +### Why keep-alive does not park it today + +Gate 1's three Claude-shaped pieces, one level in, plus a vocabulary gap of its own: the hook's +reply protocol has only allow and deny, so even a parked session would have no way to tell the +hook "suspend and wait." The wait itself is as holdable as Gate 1's: it is the same file poll, +alive as long as the session is. + +### Options and recommendation + +The mechanism is the relay, so the options are Gate 1's options, and the same recommendation +applies: Option C, proven by the spike (whose `tool_call` hook is exactly this gate's shape). +Under C this gate gets simpler than it is today: the hook awaits `ctx.ui.confirm` and the +answer is the answer; the fail-closed default is the dialog resolving false on any +cancellation. The extra constraint below applies only if the Option B fallback is ever built. +Under B the paused state must become a genuine third outcome (suspend and keep polling), +distinct from allow and deny, held open only while keep-alive holds the session; the watcher +must stop answering an ask instantly with `pendingApproval`, because fail-closed makes the hook +treat that as final. When keep-alive cannot park, the hook keeps today's fail-closed timeout, +so a builtin never runs unapproved. + +The same turn-versus-prompt reading from Gate 1 applies: on the warm path nothing new is ever +prompted into Pi. The approval arrives as a new `/run` request, the runner writes the +permission response (B) or resolves the held dialog (C), the blocked hook returns, and the +builtin runs inside the original still-open `prompt()`. A genuinely new agent turn happens only +on the cold path. + +Because both Pi gates ride the same mechanism, they should be built as one change, not two. + +One fact the experiments added: Pi flushes the assistant message carrying the pending tool call +to disk on `message_end`, strictly before this hook runs. So at the moment a Pi gate parks, the +pending call is already durable in Pi's own session file. If a parked Pi session later dies +(TTL expiry, crash, eviction), the disk still holds everything tier 2 needs; the degradation +path is a faithful session-resume continuation, not a total loss. + +## Gate 3: the client-tool MCP pause + +### How it pauses today + +Claude's client tools (for example `request_connection`) are served by an internal loopback MCP +server the runner starts per session (`tool-mcp-http.ts:271`, `startInternalToolMcpServer`). Claude +reaches it as a `type: "http"` MCP server. When a client tool is called, the handler returns the +`MCP_PAUSED` sentinel instead of a JSON-RPC result, and the request listener calls `abortPaused` +(`tool-mcp-http.ts:284-287`, `res.destroy()` with no body), from `:348-352` for a single call and +`:325-327` for a batch. + +The destroy is deliberate. The comment at `tool-mcp-http.ts:54-62` explains it: "a normal MCP +result would let the harness (Claude) settle the call and clobber the pending connect widget +before the paused turn is observed." So the pause is expressed *by destroying the transport*. Any +returned result, even an empty one, would tell Claude the tool produced output, and Claude would +settle the call and move on, overwriting the "waiting to connect" widget the frontend just showed. +Destroying the socket leaves the call unsettled, and the runner ends the turn `paused`. + +There is also a teardown backstop: an engine abort signal destroys any in-flight request socket +(`tool-mcp-http.ts:386-394`). The comment there is honest about its limit: it "suppresses the +response but does not cancel execution." A `runResolvedTool` already dispatched keeps running +server-side; threading the signal into dispatch is a known follow-up. + +### Why it is not parkable + +The runner destroyed the request. There is no held socket, no promise, nothing to answer. On +resume, the browser output comes back the same way an approval does: a fresh turn cold-replays, +Claude re-issues the client tool, and the fulfilled output is folded in from the transcript. As +with every cold-replay path, the re-issued call can drift from the original. + +### Options + +**Option A: hold the MCP socket open instead of destroying it.** Do not write a body and do not +destroy. Leave the request hanging, so Claude's MCP client keeps waiting and the call stays +unsettled. When the browser fulfills the client tool after resume, the runner writes the real +JSON-RPC result into that same held socket, and Claude continues. + +- This directly answers the widget-clobber constraint: an unsettled call cannot clobber the + widget, and no body is written until the real output exists, so there is never a premature + settlement. It is the exact analog of the Claude ACP held request. +- The honest catch is an asymmetry with the ACP gate. For the ACP gate the held connection is the + runner's *own* undici fetch to the daemon, so the runner disabled its *own* client-side timeout + (`acp-fetch.ts`). Here the held socket is *Claude's* MCP client connecting into the runner. The + runner owns the server side, not Claude's client-side request timeout. If Claude's MCP client + reaps a request that produces no headers for long enough, holding the socket open survives only + until that timeout, not for an arbitrary park. Whether that timeout is long enough for the idle + TTL (60 seconds) and the approval TTL (5 minutes) has to be measured; the 2026-07-09 + experiments did not measure it, so it remains the load-bearing open question for this gate. + +**Option B: destroy as today, but re-answer the same call after resume without the harness +re-issuing it.** Keep the socket destroy, but on resume inject the browser output straight into +the harness rather than replaying the transcript and letting Claude re-issue the tool. + +- Trade-off: with the socket destroyed and the turn ended, there is no open call to inject into. + Making the harness hold a client-tool call open across a turn boundary is exactly what the + destroy exists to prevent. The experiments confirmed the general form of this: once a pending + call has been settled on the harness side, no load path will answer it; the model re-issues. + This option is, in practice, today's cold-replay path wearing a different name; it does not + remove the drift. Rejected as a parkability mechanism. + +### Recommended option + +**Option A, gated on keep-alive and on the measured client timeout, with a clean fall-back to +today's destroy.** Hold the socket open only while keep-alive is on and only for as long as +Claude's MCP client keeps the request open. If a park would outlast that client timeout, fall back +to the current destroy-and-cold-replay, so nothing regresses. The result is that client tools +become parkable for short windows (very likely the idle TTL, possibly the approval TTL) and stay +cold for long ones. This is a weaker guarantee than the Claude ACP gate, and the reason is +structural: the runner does not own the client side of this socket. State that limit plainly +rather than promise a park the transport cannot hold. + +--- + +## The cold path: every gate, when the answer comes after the park is gone + +Every gate needs the pair: a warm path (the park above) and a cold path for when the park +cannot help: keep-alive off, pool full, approval TTL expired, sandbox died, human answered +overnight. The cold path is not something this design builds; it exists today for all four +gates and stays the universal fall-back. What makes it work is that the park is only a cache. +The durable record is written at pause time regardless: the `session_interactions` row, plus +the approval card in the conversation whose decision the frontend folds into the next request +(the decision map). A late answer always finds that record. + +| Gate | Durable at pause | Cold resume today | After session resume lands | What the user sees | +|---|---|---|---|---| +| Claude ACP permission | interaction row + the decision in the next request | cold replay; the model re-issues the call; the decision map matches it by name plus canonical arguments and answers the fresh gate | `session/load` continues with full structured history; the parked call is settled as errored, the model re-issues, the decision map answers it | click Approve, wait a cold rebuild (tens of seconds), then the tool runs | +| Pi custom-tool relay | same | cold replay; Pi re-issues; the relay's `decide()` consults the decision map and executes on a match | same shape, without the flatten loss | same | +| Pi builtin | same | cold replay; the hook's permission check hits the decision map and allows | same shape | same | +| Client-tool MCP | interaction row + the browser-fulfilled output in the next request | cold replay; Claude re-issues the client tool; the relay answers it with the fulfilled output (`relay.ts:229-237`) | same shape | fulfill in the browser, then a cold rebuild folds the output in | + +Two facts bound this table. First, the experiments settled what the "after session resume" +column can ever be: rubric B. The re-issued call carries full structured context but a new id +and regenerated arguments; no load path answers the original call (report, Verdicts). So every +cold resume, today and after session resume, carries the drift risk the decision map exists to +absorb: a re-issued call whose arguments drift re-fires the gate instead of running +unapproved. Fail-closed, never wrong, sometimes one extra click. Second, the warm path must +never consume the durable record. The row and the decision map are written at pause time +whether or not a park exists, so whoever answers first wins and a late answer still lands. +That is already slice 2's behavior for the Claude gate, and the new gates inherit it. + +--- + +## How this composes with keep-alive, session resume, and the interactions plane + +These gates only become parkable inside keep-alive; a parked handle is worthless if the session +that owns it was torn down. So this work sits on top of keep-alive slices 1 and 2, and it slots +into the tier model above, which extends the two-tier picture in architecture-notes.md with the +middle tier the experiments defined. + +- **The parked handle is tier 1, the fast in-memory tier.** A parked Pi gate (a held ACP + permission request, Option C) or a held MCP socket (client tool) resumes the original call + with no replay, valid for the approval TTL. The only byte-exact tier. +- **Harness session resume is tier 2.** When the parked process is gone but the harness session + file survives, `session/load` continues the conversation with full structured history; the + parked call is settled and re-issued. This tier belongs to the harness-session-resume project, + not to this one. +- **The durable interaction row is tier 3, the slow tier.** The runner already writes a + `session_interactions` row on pause and resolves it on the decision (for committed revisions). + When the answer comes after the TTL, from another surface, the live session is gone; the answer + settles the row and a resume replays cold. The stored decision is what makes the re-issued + call deterministic rather than a fresh model guess. +- **Whoever answers first wins.** A quick click resumes the live call through the parked handle; a + late answer settles the durable row and replays cold (or, once tier 2 ships, resumes + faithfully). This work does not build the cross-plane resolver; it makes the fast lane real for + three more gates and leaves the row untouched. + +The fall-back rule is the same one keep-alive already lives by: every one of these gates, when it +cannot park (keep-alive off, TTL expired, client timeout too short, session gone), degrades to +the next tier down. Nothing here can fail a turn; the worst case is a cold restart. + +Three slice-2 realities shape how the new parked handles validate and scope, and each new gate +must adopt them rather than reinvent: + +- **Resume validation checks the decision, the history, and the mount expiry; nothing else.** + The approval-resume dispatch (`server.ts`, the `awaiting_approval` branch) deliberately does + NOT require the resume request's config fingerprint or credential epoch to equal the parked + session's. The backend re-mints per-request secret material (resolved secret values, the + per-turn tool-callback bearer), so the incoming epoch never matches a park, and demanding + equality would evict a good live session on every approval. What bounds a park is the + approval-decision match for the parked tool-call id, the history fingerprint, and the parked + mount credentials' own expiry. A parked Pi or client-tool handle must use the same rule. +- **The approval TTL default is 5 minutes** (`DEFAULT_APPROVAL_TTL_MS = 300_000` in + `session-pool.ts`, overridable via `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS`). That is the + ceiling any tier-1 park designs against. +- **The pool's project scope prefers the server-stamped run context.** The pool key takes its + project id from `runContext.project.id` when the service stamps it, and falls back to the + mount's owning project id (`session-pool.ts:345-376`). New parked handles inherit the pool key + as-is. + +## Ownership and ordering + +This is an incremental follow-up **after** keep-alive slices 1 and 2 have run in real use. It is +not part of shipping keep-alive. And it is not a standalone build: the warm-session machinery is +moving into the backend, and harness session resume (tier 2) is in progress, both owned by JP. +Those two efforts reshape the same pause, park, and resume code this design would touch. So the +Pi gate park and the client-tool hold-open land on top of, or inside, that work, on its +schedule; building them against the current runner-local pool would produce a conflict, not a +head start. Parkable gates and session resume are two tiers of one invariant, not two features, +and they should be planned as one roadmap. + +- **v-next (the Pi gate park, Option C).** Both Pi gates as one change on the proven + mechanism: the gate awaits `ctx.ui.confirm` with the JSON envelope, the bridge raises a real + ACP permission request, and slice 2's park machinery holds it. The two runner work items are + the envelope parsing and the extension switch; the one residual is a live daemon-path + confidence run. This is the larger and higher-value piece, because Pi has no tier-1 path + today at all. Coordinate with the backend warm-session move; the park record should live + wherever the pool lands. +- **v-next (client tools), gated on a measurement.** Option A for the client-tool MCP pause: hold + the socket open. Ship it only after measuring Claude's MCP client request timeout and confirming + it covers at least the idle TTL. If it does not, hold for the idle TTL only and keep cold-replay + for the approval TTL. +- **later (the cleanup).** Upstream a structured-metadata field to `pi-acp` (maintainer + Sergii Kozak, `svkozak/pi-acp`; or carry a pnpm patch, which this repo already does for + `sandbox-agent`) so the JSON envelope encoding disappears from the extension and the + runner's parser becomes a plain field read. + +--- + +## Risks and open questions + +- **Ending a turn while an in-band tool call is suspended (Pi).** The Claude gate ends a turn + while holding an out-of-band request; the Pi gate ends a turn while Pi's `prompt()` is still + inside a blocked hook or `execute`. The spike retired the harness half of this risk: a Pi + session held a gate open for three minutes with no reaper and resumed the original call with + its original arguments, in one uninterrupted `prompt()`. What remains is the runner half: + emitting `paused` on the egress and running the turn-end bookkeeping (the latch-loser sweep, + the orphaned-tool-call settle) while the dialog request stays held, without Pi emitting an + error or a spurious result. That is the same shape slice 2 already handles for Claude, and + the two experiments bound the blast radius (Pi's session file holds the pending call + mid-block, so a park that dies degrades to a tier-2 continuation), but it should be watched + in the build's first integration test rather than assumed. +- **The Option C residuals.** The spike proved the hop, the payload envelope, the three-minute + park, and the fail-closed unhappy paths against the real bridge; the sandbox-agent daemon leg + is source-verified but not yet live-run, so one daemon-path confidence run belongs at the + start of the build. Two smaller cautions ride with it: without envelope parsing the runner + would key the gate as `agenta-approval` with dialog-string arguments (wrong identity on + cards, the decision map, and policy), so the parsing work item is correctness-critical, and + the no-reaper dialog behavior is a property of Pi's RPC helper at the pinned version, so a Pi + upgrade should re-run the hold scenario. +- **Claude's MCP client timeout (client tools).** Whether Option A survives the idle TTL and the + approval TTL depends entirely on a timeout the runner does not own. It is still unmeasured; the + 2026-07-09 experiments covered kill-and-resume behavior, not this. Measure it before committing + to hold-open past the idle window. If it is short, the client-tool gate parks briefly and stays + cold for long waits, and that is the ceiling. +- **Fail-closed must stay fail-closed off the park path.** Both Pi gates are fail-closed today for + a reason: returning nothing lets Pi run the builtin. The park-aware wait must be unbounded *only* + while keep-alive holds the session; every other path (keep-alive off, no parkable session, + timeout) must keep the bounded deny-on-timeout behavior, or a gate could silently let a call run. +- **The teardown backstop still does not cancel execution.** The abort signal + (`tool-mcp-http.ts:386-394`) suppresses a response but leaves a dispatched `runResolvedTool` + running. Holding sockets open (Option A) does not change that; threading the signal into dispatch + remains a separate known follow-up, and a parked client tool that is later abandoned must not + leave a tool running with nowhere to report. +- **Credentials on the resumed call.** A parked Pi or client-tool handle executes with the + original turn's baked credentials, the same as the Claude park. The bound is the slice-2 + validation rule above: decision match, history fingerprint, and the parked mount credentials' + expiry. A park that outlives its mount credentials evicts to the next tier down. No config or + epoch equality is demanded on the resume, because the backend re-mints per-request material. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/acp-client.mjs b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/acp-client.mjs new file mode 100644 index 0000000000..1127ed87db --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/acp-client.mjs @@ -0,0 +1,201 @@ +// Option C spike ACP client. +// +// Drives `pi-acp` (which spawns `pi --mode rpc`) over newline-delimited ACP JSON-RPC on +// stdio, using the same @agentclientprotocol/sdk pi-acp itself uses. Logs every ACP message +// verbatim and drives one of four scenarios against the spike extension's approval gate: +// +// SCENARIO=allow -> answer the permission "yes" immediately +// SCENARIO=deny -> answer "no" immediately +// SCENARIO=hold -> hold the permission open for HOLD_MS, then answer "yes" (park test) +// SCENARIO=drop -> never answer; after HOLD_MS drop the client transport (EOF to pi-acp) +// +// Everything is logged to $LOGDIR: transcript.jsonl (structured), raw-in.log / raw-out.log +// (verbatim wire bytes), plus pi-stderr.log written by the pi wrapper. + +import { spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { Readable, Writable, PassThrough } from "node:stream"; +import { join } from "node:path"; + +const SDK = + "/home/mahmoud/code/agenta/services/runner/node_modules/.pnpm/@agentclientprotocol+sdk@0.26.0_zod@3.25.76/node_modules/@agentclientprotocol/sdk/dist/acp.js"; +const { ClientSideConnection, ndJsonStream } = await import(SDK); + +const SP = "/tmp/agenta-spike-c"; +const PI_ACP = "/home/mahmoud/code/agenta/services/runner/node_modules/pi-acp/dist/index.js"; + +const SCENARIO = process.env.SCENARIO || "allow"; +const HOLD_MS = parseInt(process.env.HOLD_MS || "0", 10); +const LOGDIR = process.env.LOGDIR || join(SP, "logs", SCENARIO); +const TOKEN = process.env.TOKEN || "TOKEN-DEFAULT"; +const PROMPT = + process.env.PROMPT || + `Call the park_probe tool exactly once with token "${TOKEN}". Do not call any other tool. After the tool result, reply with just the word done.`; + +import { mkdirSync } from "node:fs"; +mkdirSync(LOGDIR, { recursive: true }); + +const transcript = createWriteStream(join(LOGDIR, "transcript.jsonl")); +const rawIn = createWriteStream(join(LOGDIR, "raw-in.log")); +const rawOut = createWriteStream(join(LOGDIR, "raw-out.log")); + +function ts() { + return new Date().toISOString(); +} +function rec(dir, event, data) { + const line = JSON.stringify({ t: ts(), dir, event, data }); + transcript.write(line + "\n"); + process.stdout.write(`${dir} ${event} ${data ? JSON.stringify(data).slice(0, 300) : ""}\n`); +} + +const child = spawn("node", [PI_ACP], { + cwd: join(SP, "proj"), + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME: process.env.HOME, + PI_CODING_AGENT_DIR: join(SP, "piagent"), + PI_ACP_PI_COMMAND: join(SP, "run-pi.sh"), + }, +}); + +child.stderr.setEncoding("utf8"); +const acpErr = createWriteStream(join(LOGDIR, "pi-acp-stderr.log")); +child.stderr.on("data", (d) => acpErr.write(d)); + +// tee agent->client bytes to raw-in.log, feed a PassThrough to the ndjson decoder +const inTee = new PassThrough(); +child.stdout.on("data", (c) => { + rawIn.write(c); + inTee.write(c); +}); +child.stdout.on("end", () => inTee.end()); + +// tee client->agent bytes to raw-out.log, then to child.stdin +const outTee = new PassThrough(); +outTee.on("data", (c) => rawOut.write(c)); +outTee.pipe(child.stdin); + +const input = Readable.toWeb(inTee); +const output = Writable.toWeb(outTee); +const stream = ndJsonStream(output, input); + +let dropped = false; + +const client = { + async sessionUpdate(params) { + const u = params.update || {}; + rec("A->C", "session/update", { sessionId: params.sessionId, kind: u.sessionUpdate, update: u }); + }, + async requestPermission(params) { + rec("A->C", "session/request_permission", params); + const title = params.toolCall && params.toolCall.title; + const options = params.options || []; + const pick = (want) => { + const o = + options.find((x) => x.optionId === want) || + options.find((x) => (x.kind || "").includes("allow")) || + options[0]; + return o && o.optionId; + }; + + // Anything that is not our gate (e.g. a project-trust prompt) -> auto-allow to proceed. + if (title !== "agenta-approval") { + const optionId = pick("yes"); + rec("C->A", "permission-auto-allow(non-gate)", { title, optionId }); + return { outcome: { outcome: "selected", optionId } }; + } + + if (SCENARIO === "rejecterr") { + // Reject the ACP request itself (daemon stays alive). pi-acp's requestExtensionPermission + // catch should map this to a cancelled dialog -> confirm resolves false -> hook denies. + rec("C->A", "permission-answer", { decision: "throw-request-error" }); + const err = new Error("client refuses permission (simulated ACP error)"); + throw err; + } + if (SCENARIO === "allow") { + rec("C->A", "permission-answer", { decision: "allow" }); + return { outcome: { outcome: "selected", optionId: pick("yes") } }; + } + if (SCENARIO === "deny") { + rec("C->A", "permission-answer", { decision: "deny" }); + return { outcome: { outcome: "selected", optionId: options.find((x) => x.optionId === "no")?.optionId || "no" } }; + } + if (SCENARIO === "hold" || SCENARIO === "drop") { + const start = Date.now(); + rec("HOLD", "park-begin", { holdMs: HOLD_MS, scenario: SCENARIO }); + // heartbeat so we can prove the request stays pending and nothing reaps it + const hb = setInterval(() => { + rec("HOLD", "still-pending", { elapsedMs: Date.now() - start }); + }, 15000); + await new Promise((r) => setTimeout(r, HOLD_MS)); + clearInterval(hb); + if (SCENARIO === "drop") { + rec("DROP", "dropping-transport", { elapsedMs: Date.now() - start }); + dropped = true; + // Simulate the ACP connection dropping while the request is pending: + // EOF pi-acp's stdin and tear down our read side, never answering. + try { child.stdin.end(); } catch {} + try { child.stdout.destroy(); } catch {} + // give pi-acp/pi a moment to react, capture stderr, then exit + setTimeout(() => finish(0), 6000); + // Return a never-resolving promise; the transport is gone anyway. + return new Promise(() => {}); + } + rec("C->A", "permission-answer", { decision: "allow-after-hold", elapsedMs: Date.now() - start }); + return { outcome: { outcome: "selected", optionId: pick("yes") } }; + } + return { outcome: { outcome: "cancelled" } }; + }, + async readTextFile() { + throw new Error("readTextFile not supported in spike"); + }, + async writeTextFile() { + return {}; + }, +}; + +const conn = new ClientSideConnection(() => client, stream); + +function finish(code) { + rec("CLIENT", "finish", { code }); + try { child.kill("SIGKILL"); } catch {} + setTimeout(() => process.exit(code), 300); +} + +(async () => { + try { + const initRes = await conn.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + rec("C->A", "initialize.result", initRes); + + const sess = await conn.newSession({ cwd: join(SP, "proj"), mcpServers: [] }); + rec("C->A", "session/new.result", sess); + const sessionId = sess.sessionId; + + rec("C->A", "session/prompt.send", { prompt: PROMPT }); + const promptRes = await conn.prompt({ + sessionId, + prompt: [{ type: "text", text: PROMPT }], + }); + rec("A->C", "session/prompt.result", promptRes); + finish(0); + } catch (err) { + if (dropped) { + rec("CLIENT", "post-drop-error(expected)", { message: String(err && err.message ? err.message : err) }); + return; + } + rec("CLIENT", "fatal", { message: String(err && err.stack ? err.stack : err) }); + finish(1); + } +})(); + +// safety net: never run forever +setTimeout(() => { + rec("CLIENT", "watchdog-timeout", {}); + finish(2); +}, parseInt(process.env.MAX_MS || "180000", 10)); diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/allow-transcript.jsonl b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/allow-transcript.jsonl new file mode 100644 index 0000000000..c619a89725 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/allow-transcript.jsonl @@ -0,0 +1,27 @@ +{"t":"2026-07-09T17:41:21.024Z","dir":"C->A","event":"initialize.result","data":{"protocolVersion":1,"agentInfo":{"name":"pi-acp","title":"pi ACP adapter","version":"0.0.29"},"authMethods":[{"id":"pi_terminal_login","name":"Launch pi in the terminal","description":"Start pi in an interactive terminal to configure API keys or login","type":"terminal","args":["--terminal-login"],"env":{}}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"list":{}}}}} +{"t":"2026-07-09T17:41:21.507Z","dir":"C->A","event":"session/new.result","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","configOptions":[{"type":"select","id":"model","category":"model","name":"Model","description":"Select the model for this session","currentValue":"openai-codex/gpt-5.5","options":[{"value":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"value":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"value":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"value":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}]},{"type":"select","id":"thought_level","category":"thought_level","name":"Thinking","description":"Set the reasoning effort for this session","currentValue":"low","options":[{"value":"off","name":"Thinking: off","description":null},{"value":"minimal","name":"Thinking: minimal","description":null},{"value":"low","name":"Thinking: low","description":null},{"value":"medium","name":"Thinking: medium","description":null},{"value":"high","name":"Thinking: high","description":null},{"value":"xhigh","name":"Thinking: xhigh","description":null}]}],"models":{"availableModels":[{"modelId":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"modelId":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"modelId":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"modelId":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}],"currentModelId":"openai-codex/gpt-5.5"},"modes":{"currentModeId":"low","availableModes":[{"id":"off","name":"Thinking: off","description":null},{"id":"minimal","name":"Thinking: minimal","description":null},{"id":"low","name":"Thinking: low","description":null},{"id":"medium","name":"Thinking: medium","description":null},{"id":"high","name":"Thinking: high","description":null},{"id":"xhigh","name":"Thinking: xhigh","description":null}]},"_meta":{"piAcp":{"startupInfo":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n"}}}} +{"t":"2026-07-09T17:41:21.507Z","dir":"C->A","event":"session/prompt.send","data":{"prompt":"Call the park_probe tool exactly once with token \"TOKEN-ALLOW-a1b2\". Do not call any other tool. After the tool result, reply with just the word done."}} +{"t":"2026-07-09T17:41:21.512Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"agent_message_chunk","update":{"content":{"text":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:41:21.516Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":true}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:41:21.517Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"available_commands_update","update":{"availableCommands":[{"name":"compact","description":"Manually compact the session context","input":{"hint":"optional custom instructions"}},{"name":"autocompact","description":"Toggle automatic context compaction","input":{"hint":"on|off|toggle"}},{"name":"export","description":"Export session to an HTML file in the session cwd"},{"name":"session","description":"Show session stats (messages, tokens, cost, session file)"},{"name":"name","description":"Set session display name","input":{"hint":""}},{"name":"steering","description":"Get/set pi steering message delivery mode (how queued steering messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"follow-up","description":"Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"changelog","description":"Show pi changelog"}],"sessionUpdate":"available_commands_update"}}} +{"t":"2026-07-09T17:41:23.646Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","title":"park_probe","kind":"other","status":"pending","rawInput":{},"sessionUpdate":"tool_call"}}} +{"t":"2026-07-09T17:41:23.664Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.675Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.694Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":""},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.717Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.731Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.733Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.760Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.774Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a1"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.793Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a1b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.814Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a1b2"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.831Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a1b2"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.920Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"pending","rawInput":{"token":"TOKEN-ALLOW-a1b2"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.923Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"in_progress","rawInput":{"token":"TOKEN-ALLOW-a1b2"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:23.929Z","dir":"A->C","event":"session/request_permission","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","toolCall":{"toolCallId":"pi-ui-8f9a1309-c33e-4e3a-8113-0578bb2d85fd","kind":"other","status":"pending","title":"agenta-approval","rawInput":{"method":"confirm","title":"agenta-approval","message":"{\"v\":1,\"gate\":\"pi-custom-tool\",\"harness\":\"pi\",\"toolName\":\"park_probe\",\"toolCallId\":\"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267\",\"input\":{\"token\":\"TOKEN-ALLOW-a1b2\"},\"probe\":\"quotes\\\"and\\\\back\\\\slashes and 日本語 and \\n newline\"}"}},"options":[{"optionId":"yes","name":"Yes","kind":"allow_once"},{"optionId":"no","name":"No","kind":"reject_once"}]}} +{"t":"2026-07-09T17:41:23.929Z","dir":"C->A","event":"permission-answer","data":{"decision":"allow"}} +{"t":"2026-07-09T17:41:23.933Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"tool_call_update","update":{"toolCallId":"call_6JFTMf2YhXxrJabOwbQF99Nk|fc_0d79610fd4703900016a4fdd43a00081919e902010224c8267","status":"completed","content":[{"content":{"text":"EXECUTED park_probe token=TOKEN-ALLOW-a1b2","type":"text"},"type":"content"}],"rawOutput":{"content":[{"type":"text","text":"EXECUTED park_probe token=TOKEN-ALLOW-a1b2"}],"details":{"toolName":"park_probe"}},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:41:24.607Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"agent_message_chunk","update":{"content":{"text":"done","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:41:24.760Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-4795-741d-84fc-a2ab6fd09e19","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":false}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:41:24.761Z","dir":"A->C","event":"session/prompt.result","data":{"stopReason":"end_turn"}} +{"t":"2026-07-09T17:41:24.761Z","dir":"CLIENT","event":"finish","data":{"code":0}} diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/deny-transcript.jsonl b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/deny-transcript.jsonl new file mode 100644 index 0000000000..f88c470b34 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/deny-transcript.jsonl @@ -0,0 +1,28 @@ +{"t":"2026-07-09T17:42:02.969Z","dir":"C->A","event":"initialize.result","data":{"protocolVersion":1,"agentInfo":{"name":"pi-acp","title":"pi ACP adapter","version":"0.0.29"},"authMethods":[{"id":"pi_terminal_login","name":"Launch pi in the terminal","description":"Start pi in an interactive terminal to configure API keys or login","type":"terminal","args":["--terminal-login"],"env":{}}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"list":{}}}}} +{"t":"2026-07-09T17:42:03.403Z","dir":"C->A","event":"session/new.result","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","configOptions":[{"type":"select","id":"model","category":"model","name":"Model","description":"Select the model for this session","currentValue":"openai-codex/gpt-5.5","options":[{"value":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"value":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"value":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"value":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}]},{"type":"select","id":"thought_level","category":"thought_level","name":"Thinking","description":"Set the reasoning effort for this session","currentValue":"low","options":[{"value":"off","name":"Thinking: off","description":null},{"value":"minimal","name":"Thinking: minimal","description":null},{"value":"low","name":"Thinking: low","description":null},{"value":"medium","name":"Thinking: medium","description":null},{"value":"high","name":"Thinking: high","description":null},{"value":"xhigh","name":"Thinking: xhigh","description":null}]}],"models":{"availableModels":[{"modelId":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"modelId":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"modelId":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"modelId":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}],"currentModelId":"openai-codex/gpt-5.5"},"modes":{"currentModeId":"low","availableModes":[{"id":"off","name":"Thinking: off","description":null},{"id":"minimal","name":"Thinking: minimal","description":null},{"id":"low","name":"Thinking: low","description":null},{"id":"medium","name":"Thinking: medium","description":null},{"id":"high","name":"Thinking: high","description":null},{"id":"xhigh","name":"Thinking: xhigh","description":null}]},"_meta":{"piAcp":{"startupInfo":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n"}}}} +{"t":"2026-07-09T17:42:03.404Z","dir":"C->A","event":"session/prompt.send","data":{"prompt":"Call the park_probe tool exactly once with token \"TOKEN-DENY-d3d4\". Do not call any other tool. After the tool result, reply with just the word done."}} +{"t":"2026-07-09T17:42:03.412Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":true}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:42:03.413Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"agent_message_chunk","update":{"content":{"text":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:42:03.425Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"available_commands_update","update":{"availableCommands":[{"name":"compact","description":"Manually compact the session context","input":{"hint":"optional custom instructions"}},{"name":"autocompact","description":"Toggle automatic context compaction","input":{"hint":"on|off|toggle"}},{"name":"export","description":"Export session to an HTML file in the session cwd"},{"name":"session","description":"Show session stats (messages, tokens, cost, session file)"},{"name":"name","description":"Set session display name","input":{"hint":""}},{"name":"steering","description":"Get/set pi steering message delivery mode (how queued steering messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"follow-up","description":"Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"changelog","description":"Show pi changelog"}],"sessionUpdate":"available_commands_update"}}} +{"t":"2026-07-09T17:42:05.244Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","title":"park_probe","kind":"other","status":"pending","rawInput":{},"sessionUpdate":"tool_call"}}} +{"t":"2026-07-09T17:42:05.272Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.281Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.283Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":""},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.298Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.317Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-D"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.346Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.360Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.380Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.402Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d3"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.416Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d3d"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.434Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d3d4"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.454Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d3d4"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.576Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"pending","rawInput":{"token":"TOKEN-DENY-d3d4"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.584Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"in_progress","rawInput":{"token":"TOKEN-DENY-d3d4"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:05.591Z","dir":"A->C","event":"session/request_permission","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","toolCall":{"toolCallId":"pi-ui-78461041-d927-41f3-86bb-f01ec7fcca21","kind":"other","status":"pending","title":"agenta-approval","rawInput":{"method":"confirm","title":"agenta-approval","message":"{\"v\":1,\"gate\":\"pi-custom-tool\",\"harness\":\"pi\",\"toolName\":\"park_probe\",\"toolCallId\":\"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529\",\"input\":{\"token\":\"TOKEN-DENY-d3d4\"},\"probe\":\"quotes\\\"and\\\\back\\\\slashes and 日本語 and \\n newline\"}"}},"options":[{"optionId":"yes","name":"Yes","kind":"allow_once"},{"optionId":"no","name":"No","kind":"reject_once"}]}} +{"t":"2026-07-09T17:42:05.591Z","dir":"C->A","event":"permission-answer","data":{"decision":"deny"}} +{"t":"2026-07-09T17:42:05.596Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"tool_call_update","update":{"toolCallId":"call_SHBGAUpFOuxmbOWGcATqUYGg|fc_0b67be9c181b50dc016a4fdd6d3a088191b4f2036828829529","status":"failed","content":[{"content":{"text":"denied by agenta spike (Option C)","type":"text"},"type":"content"}],"rawOutput":{"content":[{"type":"text","text":"denied by agenta spike (Option C)"}],"details":{}},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:06.673Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"agent_message_chunk","update":{"content":{"text":"done","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:42:06.763Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f8-eb79-729e-9142-c46551f91959","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":false}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:42:06.763Z","dir":"A->C","event":"session/prompt.result","data":{"stopReason":"end_turn"}} +{"t":"2026-07-09T17:42:06.763Z","dir":"CLIENT","event":"finish","data":{"code":0}} diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/drop-transcript.jsonl b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/drop-transcript.jsonl new file mode 100644 index 0000000000..70ca05a6eb --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/drop-transcript.jsonl @@ -0,0 +1,26 @@ +{"t":"2026-07-09T17:50:07.672Z","dir":"C->A","event":"initialize.result","data":{"protocolVersion":1,"agentInfo":{"name":"pi-acp","title":"pi ACP adapter","version":"0.0.29"},"authMethods":[{"id":"pi_terminal_login","name":"Launch pi in the terminal","description":"Start pi in an interactive terminal to configure API keys or login","type":"terminal","args":["--terminal-login"],"env":{}}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"list":{}}}}} +{"t":"2026-07-09T17:50:08.104Z","dir":"C->A","event":"session/new.result","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","configOptions":[{"type":"select","id":"model","category":"model","name":"Model","description":"Select the model for this session","currentValue":"openai-codex/gpt-5.5","options":[{"value":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"value":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"value":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"value":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}]},{"type":"select","id":"thought_level","category":"thought_level","name":"Thinking","description":"Set the reasoning effort for this session","currentValue":"low","options":[{"value":"off","name":"Thinking: off","description":null},{"value":"minimal","name":"Thinking: minimal","description":null},{"value":"low","name":"Thinking: low","description":null},{"value":"medium","name":"Thinking: medium","description":null},{"value":"high","name":"Thinking: high","description":null},{"value":"xhigh","name":"Thinking: xhigh","description":null}]}],"models":{"availableModels":[{"modelId":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"modelId":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"modelId":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"modelId":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}],"currentModelId":"openai-codex/gpt-5.5"},"modes":{"currentModeId":"low","availableModes":[{"id":"off","name":"Thinking: off","description":null},{"id":"minimal","name":"Thinking: minimal","description":null},{"id":"low","name":"Thinking: low","description":null},{"id":"medium","name":"Thinking: medium","description":null},{"id":"high","name":"Thinking: high","description":null},{"id":"xhigh","name":"Thinking: xhigh","description":null}]},"_meta":{"piAcp":{"startupInfo":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n"}}}} +{"t":"2026-07-09T17:50:08.105Z","dir":"C->A","event":"session/prompt.send","data":{"prompt":"Call the park_probe tool exactly once with token \"TOKEN-DROP-7a7a\". Do not call any other tool. After the tool result, reply with just the word done."}} +{"t":"2026-07-09T17:50:08.109Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":true}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:50:08.110Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"agent_message_chunk","update":{"content":{"text":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:50:08.121Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"available_commands_update","update":{"availableCommands":[{"name":"compact","description":"Manually compact the session context","input":{"hint":"optional custom instructions"}},{"name":"autocompact","description":"Toggle automatic context compaction","input":{"hint":"on|off|toggle"}},{"name":"export","description":"Export session to an HTML file in the session cwd"},{"name":"session","description":"Show session stats (messages, tokens, cost, session file)"},{"name":"name","description":"Set session display name","input":{"hint":""}},{"name":"steering","description":"Get/set pi steering message delivery mode (how queued steering messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"follow-up","description":"Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"changelog","description":"Show pi changelog"}],"sessionUpdate":"available_commands_update"}}} +{"t":"2026-07-09T17:50:10.026Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","title":"park_probe","kind":"other","status":"pending","rawInput":{},"sessionUpdate":"tool_call"}}} +{"t":"2026-07-09T17:50:10.055Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:10.175Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.120Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":""},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.160Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.169Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-D"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.216Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.427Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:11.949Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:12.015Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:12.083Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7a7"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:12.730Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7a7a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:12.744Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7a7a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:13.014Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"pending","rawInput":{"token":"TOKEN-DROP-7a7a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:13.018Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","kind":"tool_call_update","update":{"toolCallId":"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f","status":"in_progress","rawInput":{"token":"TOKEN-DROP-7a7a"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:50:13.027Z","dir":"A->C","event":"session/request_permission","data":{"sessionId":"019f4800-50ce-7f0e-a52f-7cc3ecd93998","toolCall":{"toolCallId":"pi-ui-a6acdeab-990f-4178-b1e2-f15f04d019aa","kind":"other","status":"pending","title":"agenta-approval","rawInput":{"method":"confirm","title":"agenta-approval","message":"{\"v\":1,\"gate\":\"pi-custom-tool\",\"harness\":\"pi\",\"toolName\":\"park_probe\",\"toolCallId\":\"call_u7Tyon38KeU5Nk7LScxTW847|fc_0165e70f430cf199016a4fdf51ff5081918001383676b47a1f\",\"input\":{\"token\":\"TOKEN-DROP-7a7a\"},\"probe\":\"quotes\\\"and\\\\back\\\\slashes and 日本語 and \\n newline\"}"}},"options":[{"optionId":"yes","name":"Yes","kind":"allow_once"},{"optionId":"no","name":"No","kind":"reject_once"}]}} +{"t":"2026-07-09T17:50:13.027Z","dir":"HOLD","event":"park-begin","data":{"holdMs":15000,"scenario":"drop"}} +{"t":"2026-07-09T17:50:28.085Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":15058}} +{"t":"2026-07-09T17:50:28.086Z","dir":"DROP","event":"dropping-transport","data":{"elapsedMs":15059}} +{"t":"2026-07-09T17:50:34.117Z","dir":"CLIENT","event":"finish","data":{"code":0}} diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/hold-transcript.jsonl b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/hold-transcript.jsonl new file mode 100644 index 0000000000..feaae38c23 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/hold-transcript.jsonl @@ -0,0 +1,40 @@ +{"t":"2026-07-09T17:42:19.360Z","dir":"C->A","event":"initialize.result","data":{"protocolVersion":1,"agentInfo":{"name":"pi-acp","title":"pi ACP adapter","version":"0.0.29"},"authMethods":[{"id":"pi_terminal_login","name":"Launch pi in the terminal","description":"Start pi in an interactive terminal to configure API keys or login","type":"terminal","args":["--terminal-login"],"env":{}}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"list":{}}}}} +{"t":"2026-07-09T17:42:19.789Z","dir":"C->A","event":"session/new.result","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","configOptions":[{"type":"select","id":"model","category":"model","name":"Model","description":"Select the model for this session","currentValue":"openai-codex/gpt-5.5","options":[{"value":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"value":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"value":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"value":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}]},{"type":"select","id":"thought_level","category":"thought_level","name":"Thinking","description":"Set the reasoning effort for this session","currentValue":"low","options":[{"value":"off","name":"Thinking: off","description":null},{"value":"minimal","name":"Thinking: minimal","description":null},{"value":"low","name":"Thinking: low","description":null},{"value":"medium","name":"Thinking: medium","description":null},{"value":"high","name":"Thinking: high","description":null},{"value":"xhigh","name":"Thinking: xhigh","description":null}]}],"models":{"availableModels":[{"modelId":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"modelId":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"modelId":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"modelId":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}],"currentModelId":"openai-codex/gpt-5.5"},"modes":{"currentModeId":"low","availableModes":[{"id":"off","name":"Thinking: off","description":null},{"id":"minimal","name":"Thinking: minimal","description":null},{"id":"low","name":"Thinking: low","description":null},{"id":"medium","name":"Thinking: medium","description":null},{"id":"high","name":"Thinking: high","description":null},{"id":"xhigh","name":"Thinking: xhigh","description":null}]},"_meta":{"piAcp":{"startupInfo":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n"}}}} +{"t":"2026-07-09T17:42:19.789Z","dir":"C->A","event":"session/prompt.send","data":{"prompt":"Call the park_probe tool exactly once with token \"TOKEN-PARK-9f9f\". Do not call any other tool. After the tool result, reply with just the word done."}} +{"t":"2026-07-09T17:42:19.796Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":true}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:42:19.796Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"agent_message_chunk","update":{"content":{"text":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:42:19.805Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"available_commands_update","update":{"availableCommands":[{"name":"compact","description":"Manually compact the session context","input":{"hint":"optional custom instructions"}},{"name":"autocompact","description":"Toggle automatic context compaction","input":{"hint":"on|off|toggle"}},{"name":"export","description":"Export session to an HTML file in the session cwd"},{"name":"session","description":"Show session stats (messages, tokens, cost, session file)"},{"name":"name","description":"Set session display name","input":{"hint":""}},{"name":"steering","description":"Get/set pi steering message delivery mode (how queued steering messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"follow-up","description":"Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"changelog","description":"Show pi changelog"}],"sessionUpdate":"available_commands_update"}}} +{"t":"2026-07-09T17:42:21.056Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","title":"park_probe","kind":"other","status":"pending","rawInput":{},"sessionUpdate":"tool_call"}}} +{"t":"2026-07-09T17:42:21.076Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.096Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.116Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":""},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.116Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.137Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-P"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.159Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.183Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.200Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.220Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9f"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.244Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9f9"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.260Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9f9f"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.261Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9f9f"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.367Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"pending","rawInput":{"token":"TOKEN-PARK-9f9f"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.373Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"in_progress","rawInput":{"token":"TOKEN-PARK-9f9f"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:42:21.378Z","dir":"A->C","event":"session/request_permission","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","toolCall":{"toolCallId":"pi-ui-b34f7786-eb33-4b12-98e0-40475022c4c2","kind":"other","status":"pending","title":"agenta-approval","rawInput":{"method":"confirm","title":"agenta-approval","message":"{\"v\":1,\"gate\":\"pi-custom-tool\",\"harness\":\"pi\",\"toolName\":\"park_probe\",\"toolCallId\":\"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0\",\"input\":{\"token\":\"TOKEN-PARK-9f9f\"},\"probe\":\"quotes\\\"and\\\\back\\\\slashes and 日本語 and \\n newline\"}"}},"options":[{"optionId":"yes","name":"Yes","kind":"allow_once"},{"optionId":"no","name":"No","kind":"reject_once"}]}} +{"t":"2026-07-09T17:42:21.378Z","dir":"HOLD","event":"park-begin","data":{"holdMs":180000,"scenario":"hold"}} +{"t":"2026-07-09T17:42:36.420Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":15042}} +{"t":"2026-07-09T17:42:51.495Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":30117}} +{"t":"2026-07-09T17:43:06.570Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":45192}} +{"t":"2026-07-09T17:43:21.644Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":60266}} +{"t":"2026-07-09T17:43:36.719Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":75341}} +{"t":"2026-07-09T17:43:51.794Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":90416}} +{"t":"2026-07-09T17:44:06.859Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":105481}} +{"t":"2026-07-09T17:44:21.934Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":120556}} +{"t":"2026-07-09T17:44:37.009Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":135631}} +{"t":"2026-07-09T17:44:52.084Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":150706}} +{"t":"2026-07-09T17:45:07.159Z","dir":"HOLD","event":"still-pending","data":{"elapsedMs":165781}} +{"t":"2026-07-09T17:45:21.445Z","dir":"C->A","event":"permission-answer","data":{"decision":"allow-after-hold","elapsedMs":180067}} +{"t":"2026-07-09T17:45:21.454Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"tool_call_update","update":{"toolCallId":"call_tPhUNezjIpf1ScJp9VqRicZB|fc_05d270c5223f7157016a4fdd7d0af88191bae84043aeb1e7b0","status":"completed","content":[{"content":{"text":"EXECUTED park_probe token=TOKEN-PARK-9f9f","type":"text"},"type":"content"}],"rawOutput":{"content":[{"type":"text","text":"EXECUTED park_probe token=TOKEN-PARK-9f9f"}],"details":{"toolName":"park_probe"}},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:45:22.104Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"agent_message_chunk","update":{"content":{"text":"done","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:45:22.197Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f47f9-2b7b-739c-bc12-ddf212944a2a","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":false}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:45:22.198Z","dir":"A->C","event":"session/prompt.result","data":{"stopReason":"end_turn"}} +{"t":"2026-07-09T17:45:22.198Z","dir":"CLIENT","event":"finish","data":{"code":0}} diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/rejecterr-transcript.jsonl b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/rejecterr-transcript.jsonl new file mode 100644 index 0000000000..5706bfbb88 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/rejecterr-transcript.jsonl @@ -0,0 +1,29 @@ +{"t":"2026-07-09T17:51:09.971Z","dir":"C->A","event":"initialize.result","data":{"protocolVersion":1,"agentInfo":{"name":"pi-acp","title":"pi ACP adapter","version":"0.0.29"},"authMethods":[{"id":"pi_terminal_login","name":"Launch pi in the terminal","description":"Start pi in an interactive terminal to configure API keys or login","type":"terminal","args":["--terminal-login"],"env":{}}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"list":{}}}}} +{"t":"2026-07-09T17:51:10.412Z","dir":"C->A","event":"session/new.result","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","configOptions":[{"type":"select","id":"model","category":"model","name":"Model","description":"Select the model for this session","currentValue":"openai-codex/gpt-5.5","options":[{"value":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"value":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"value":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"value":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}]},{"type":"select","id":"thought_level","category":"thought_level","name":"Thinking","description":"Set the reasoning effort for this session","currentValue":"low","options":[{"value":"off","name":"Thinking: off","description":null},{"value":"minimal","name":"Thinking: minimal","description":null},{"value":"low","name":"Thinking: low","description":null},{"value":"medium","name":"Thinking: medium","description":null},{"value":"high","name":"Thinking: high","description":null},{"value":"xhigh","name":"Thinking: xhigh","description":null}]}],"models":{"availableModels":[{"modelId":"openai-codex/gpt-5.3-codex-spark","name":"openai-codex/GPT-5.3 Codex Spark","description":null},{"modelId":"openai-codex/gpt-5.4","name":"openai-codex/GPT-5.4","description":null},{"modelId":"openai-codex/gpt-5.4-mini","name":"openai-codex/GPT-5.4 mini","description":null},{"modelId":"openai-codex/gpt-5.5","name":"openai-codex/GPT-5.5","description":null}],"currentModelId":"openai-codex/gpt-5.5"},"modes":{"currentModeId":"low","availableModes":[{"id":"off","name":"Thinking: off","description":null},{"id":"minimal","name":"Thinking: minimal","description":null},{"id":"low","name":"Thinking: low","description":null},{"id":"medium","name":"Thinking: medium","description":null},{"id":"high","name":"Thinking: high","description":null},{"id":"xhigh","name":"Thinking: xhigh","description":null}]},"_meta":{"piAcp":{"startupInfo":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n"}}}} +{"t":"2026-07-09T17:51:10.412Z","dir":"C->A","event":"session/prompt.send","data":{"prompt":"Call the park_probe tool exactly once with token \"TOKEN-REJ-5b5b\". Do not call any other tool. After the tool result, reply with just the word done."}} +{"t":"2026-07-09T17:51:10.420Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":true}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:51:10.421Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"agent_message_chunk","update":{"content":{"text":"## Extensions\n- /home/mahmoud/.pi/agent/extensions/agenta.js\n","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:51:10.435Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"available_commands_update","update":{"availableCommands":[{"name":"compact","description":"Manually compact the session context","input":{"hint":"optional custom instructions"}},{"name":"autocompact","description":"Toggle automatic context compaction","input":{"hint":"on|off|toggle"}},{"name":"export","description":"Export session to an HTML file in the session cwd"},{"name":"session","description":"Show session stats (messages, tokens, cost, session file)"},{"name":"name","description":"Set session display name","input":{"hint":""}},{"name":"steering","description":"Get/set pi steering message delivery mode (how queued steering messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"follow-up","description":"Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)","input":{"hint":"(no args to show) all | one-at-a-time"}},{"name":"changelog","description":"Show pi changelog"}],"sessionUpdate":"available_commands_update"}}} +{"t":"2026-07-09T17:51:12.760Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","title":"park_probe","kind":"other","status":"pending","rawInput":{},"sessionUpdate":"tool_call"}}} +{"t":"2026-07-09T17:51:12.785Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.793Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.809Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":""},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.838Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.853Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.879Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-RE"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.891Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.907Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.928Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.945Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.964Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5b5"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.984Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5b5b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:12.984Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5b5b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:13.075Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"pending","rawInput":{"token":"TOKEN-REJ-5b5b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:13.077Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"in_progress","rawInput":{"token":"TOKEN-REJ-5b5b"},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:13.078Z","dir":"A->C","event":"session/request_permission","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","toolCall":{"toolCallId":"pi-ui-5fcf8de7-1546-444b-a69e-80883b781451","kind":"other","status":"pending","title":"agenta-approval","rawInput":{"method":"confirm","title":"agenta-approval","message":"{\"v\":1,\"gate\":\"pi-custom-tool\",\"harness\":\"pi\",\"toolName\":\"park_probe\",\"toolCallId\":\"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0\",\"input\":{\"token\":\"TOKEN-REJ-5b5b\"},\"probe\":\"quotes\\\"and\\\\back\\\\slashes and 日本語 and \\n newline\"}"}},"options":[{"optionId":"yes","name":"Yes","kind":"allow_once"},{"optionId":"no","name":"No","kind":"reject_once"}]}} +{"t":"2026-07-09T17:51:13.078Z","dir":"C->A","event":"permission-answer","data":{"decision":"throw-request-error"}} +{"t":"2026-07-09T17:51:13.080Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"tool_call_update","update":{"toolCallId":"call_zuEw2b4YwYVpzMKJn3zmggLS|fc_059c1191bf4b19da016a4fdf90aedc8195950492ec5a2c83b0","status":"failed","content":[{"content":{"text":"denied by agenta spike (Option C)","type":"text"},"type":"content"}],"rawOutput":{"content":[{"type":"text","text":"denied by agenta spike (Option C)"}],"details":{}},"sessionUpdate":"tool_call_update"}}} +{"t":"2026-07-09T17:51:14.193Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"agent_message_chunk","update":{"content":{"text":"done","type":"text"},"sessionUpdate":"agent_message_chunk"}}} +{"t":"2026-07-09T17:51:14.250Z","dir":"A->C","event":"session/update","data":{"sessionId":"019f4801-442d-7009-b750-232e6e032eb0","kind":"session_info_update","update":{"_meta":{"piAcp":{"queueDepth":0,"running":false}},"sessionUpdate":"session_info_update"}}} +{"t":"2026-07-09T17:51:14.251Z","dir":"A->C","event":"session/prompt.result","data":{"stopReason":"end_turn"}} +{"t":"2026-07-09T17:51:14.252Z","dir":"CLIENT","event":"finish","data":{"code":0}} diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/protocol.md b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/protocol.md new file mode 100644 index 0000000000..222f2d509f --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/protocol.md @@ -0,0 +1,121 @@ +# Spike protocol: Option C wire hop (Pi `ctx.ui.confirm` to ACP `session/request_permission`) + +This records the exact, reproducible steps run on the dev box on 2026-07-09 to prove or refute +Option C of the parkable-gates design. Option C claims a Pi approval can ride the ACP permission +plane the `pi-acp` bridge already has, so the runner's existing Claude-style park machinery holds +it unchanged. + +The spike drives a real Pi session under the real `pi-acp` bridge, over a hand-written ACP stdio +client, and answers five questions: does the hop work mid-tool-gate, does the payload survive, +does the gate park for minutes, what do deny and drop do, and does the same shape survive the +`sandbox-agent` path the runner ships on. + +Everything ran in scratch directories under `/tmp`. No git or `but` operations. The running +dev-stack containers and the subscription sidecar were left untouched. Only processes this spike +spawned were killed. + +## Environment + +- Box: hetznerdev, `/home/mahmoud/code/agenta`, Node 24.16.0. +- Pi: `@earendil-works/pi-coding-agent@0.79.4`, run via the bundled launcher at + `services/runner/node_modules/.bin/pi`. Not installed globally; the bundled one is used. +- Bridge: `pi-acp@0.0.29` at `services/runner/node_modules/pi-acp/dist/index.js` (third-party MIT + adapter by Sergii Kozak). It spawns `pi --mode rpc --no-themes`. +- ACP SDK: `@agentclientprotocol/sdk@0.26.0` (the one `pi-acp` depends on), used by the client. +- Model access: the host's codex subscription at `~/.pi/agent/auth.json` (provider + `openai-codex`, which resolves to model `gpt-5.5`). Token valid through 2026-07-10. No API key + needed and no OpenAI key used. +- Scratch root: `/tmp/agenta-spike-c/`. + +## Isolation + +Pi reads its config from an agent dir. To avoid touching the real `~/.pi/agent` (which holds the +production `agenta.js` extension and the live auth), the spike points Pi at an isolated agent dir +via `PI_CODING_AGENT_DIR=/tmp/agenta-spike-c/piagent`, containing: + +- `auth.json` — a copy of the host's codex auth (so the isolated dir can reach the model). +- `settings.json` — `defaultProvider: "openai-codex"`, `defaultModel: "gpt-5.5"`, + `defaultThinkingLevel: "low"` (cheapest working path, minimal generation). +- `trust.json` — pre-trusts the scratch cwd so no project-trust prompt fires. +- `extensions/spike.js` — the spike extension (below). + +`pi-acp` swallows the Pi child's stderr (`child.stderr.on("data", () => {})`), so the spike sets +`PI_ACP_PI_COMMAND` to a two-line wrapper (`run-pi.sh`) that execs the real `pi` with `2>>` a log +file. That is the only way to see the extension's `[spike]` stderr. + +## The spike extension (`spike-extension.js`) + +A dependency-free ESM factory placed in the isolated agent dir's `extensions/`. It: + +1. Registers one custom tool, `park_probe(token)`, whose `execute` returns + `EXECUTED park_probe token=`. Running it (and echoing the exact token) is how the spike + proves the original call ran with its original arguments. +2. Registers a `tool_call` hook. When the model calls `park_probe`, the hook builds a JSON + envelope `{v, gate, harness, toolName, toolCallId, input, probe}` — where `probe` deliberately + contains quotes, backslashes, Japanese text, and a newline — and calls + `ctx.ui.confirm("agenta-approval", JSON.stringify(envelope))`. On `true` it returns `undefined` + (allow, so `execute` runs); on `false` it returns `{ block: true, reason: ... }` (deny). + +This mirrors both Pi gates at once: the `tool_call` hook is the builtin-gate shape (Gate 2), and +gating a custom tool is the relay-gate shape (Gate 1). The hook is the exact seam the design's +Option C would use instead of the file-relay poll. + +The hook passes **no** `opts` to `confirm` (no `timeout`, no `signal`). That matters for parking: +Pi's RPC dialog helper only arms a reaper when the caller passes `opts.timeout` or an +`opts.signal` that aborts (`pi-coding-agent/dist/modes/rpc/rpc-mode.js:44-80`). With neither, the +dialog waits indefinitely and, on cancel, resolves to the default `false` (fail-closed). + +## The ACP client (`acp-client.mjs`) + +A single-file Node ESM client using `ClientSideConnection` + `ndJsonStream` from the same ACP SDK +`pi-acp` uses. It: + +1. Spawns `node pi-acp/dist/index.js` with `cwd` = the scratch project, and env + `PI_CODING_AGENT_DIR`, `PI_ACP_PI_COMMAND` set as above. +2. Tees the agent-to-client and client-to-agent byte streams to `raw-in.log` / `raw-out.log` for a + verbatim wire transcript, and logs every parsed ACP message to `transcript.jsonl`. +3. `initialize` (protocolVersion 1, fs read/write client capabilities) then `session/new` (cwd, + empty `mcpServers`) then `session/prompt` with: + `Call the park_probe tool exactly once with token "". Do not call any other tool. After + the tool result, reply with just the word done.` +4. Implements the client `requestPermission` handler. Any permission whose `toolCall.title` is not + `agenta-approval` (for example a project-trust prompt) is auto-allowed to keep the run moving. + For the `agenta-approval` gate, behavior is chosen by the `SCENARIO` env var: + - `allow` — answer `{outcome:{outcome:"selected", optionId:"yes"}}` immediately. + - `deny` — answer `optionId:"no"`. + - `hold` — log a heartbeat every 15s, wait `HOLD_MS`, then answer `yes` (the park test). + - `drop` — wait `HOLD_MS`, then EOF `pi-acp`'s stdin and destroy the read side without + answering (simulates the ACP transport dropping while the request is pending). + - `rejecterr` — throw from `requestPermission` so the ACP request itself is rejected while the + daemon stays alive (the clean connection-error path). + +## Runs executed + +| Scenario | Command (from `/tmp/agenta-spike-c`) | Purpose | +|---|---|---| +| allow | `SCENARIO=allow TOKEN=TOKEN-ALLOW-a1b2 node client.mjs` | Q1 hop + Q2 payload | +| deny | `SCENARIO=deny TOKEN=TOKEN-DENY-d3d4 node client.mjs` | Q4 deny | +| hold | `SCENARIO=hold TOKEN=TOKEN-PARK-9f9f HOLD_MS=180000 MAX_MS=280000 node client.mjs` | Q3 park (3 min) | +| drop | `SCENARIO=drop TOKEN=TOKEN-DROP-7a7a HOLD_MS=15000 node client.mjs` | Q4 transport drop | +| rejecterr | `SCENARIO=rejecterr TOKEN=TOKEN-REJ-5b5b node client.mjs` | Q4 clean reject | + +Each writes `logs//transcript.jsonl` (structured), `raw-in.log` / `raw-out.log` +(verbatim wire), and appends `[spike]` lines to `logs/pi-stderr.log`. The transcripts are copied +into `evidence/` in this folder. + +## Source-only checks (no daemon stood up) + +Q5 (the `sandbox-agent` daemon path) was verified from the shipped runner source rather than by +standing up the daemon, to avoid any risk to the running stack: + +- `services/runner/src/engines/sandbox_agent/acp-interactions.ts` — how the runner classifies and + answers every ACP `session/request_permission`. +- `acp-http-client@0.4.2/dist/index.js:448-452` — the HTTP-proxied ACP layer forwards the whole + `requestPermission(request)` to the runner's handler unchanged. + +## Teardown + +Killed the client, `pi-acp`, and the wrapped `pi` for every run (all spawned by this spike). After +the final run, `pgrep` for `client.mjs`/`pi-acp`/`run-pi.sh` returned nothing (no orphaned +process), port and dev-stack containers untouched. All outputs are new, uncommitted files under +`/tmp/agenta-spike-c` and this `spike-option-c/` folder. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/report.md b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/report.md new file mode 100644 index 0000000000..8c9e0f19b8 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/report.md @@ -0,0 +1,140 @@ +# Findings: does a Pi gate ride the ACP permission plane, and does it park? + +Date: 2026-07-09. Box: hetznerdev. Steps: [protocol.md](protocol.md). Raw transcripts: +[evidence/](evidence/). + +## The question in one line + +Option C of the parkable-gates design claims a Pi approval can stop being a file-relay poll and +become a real ACP `session/request_permission` that the runner holds as a parkable promise, +using only what exists today: Pi's extension `ctx.ui.confirm` and the `pi-acp` bridge's +extension-UI translation. If true, the runner's slice-2 park machinery (hold the request, park +the session, `respondPermission` on resume) covers Pi with a new gate type and almost no new +mechanism. The spike drove a real Pi session under the real bridge and answered five questions. + +## Verdicts + +| # | Question | Verdict | +|---|---|---| +| Q1 | Does `ctx.ui.confirm` from inside a `tool_call` hook surface as a real ACP `session/request_permission`, mid-gate? | **PROVEN.** The dialog arrives as `session/request_permission` while the tool call is pending. | +| Q2 | Does the gate's identity (tool name, call id, arguments) survive the hop? | **Natively no, with the envelope yes.** The bridge sends strings only; a JSON envelope in the message field round-trips byte-exact, including quotes, backslashes, Japanese, and newlines. | +| Q3 | Does the gate hold for minutes (park) without a reaper? | **PROVEN.** Held 180067 ms with no timeout; the late allow ran the original call with its original token inside one uninterrupted `prompt()`. | +| Q4 | What do deny, clean reject, and transport drop do? | Deny and reject resolve `confirm=false`, the hook blocks, `execute` never runs. A transport drop kills `pi-acp` and Pi cleanly; degradation is tier-2 session resume. Fail-closed everywhere. | +| Q5 | Does the same shape survive the `sandbox-agent` path the runner ships on? | **Source-verified** (one live daemon run remains as a confidence check). `acp-http-client@0.4.2` forwards `requestPermission` unchanged; the runner's responder routes a spec-less gate onto the slice-2 park machinery. | + +Bottom line: Option C works. The Pi gate becomes the same held, answerable, parkable ACP +request the Claude gate is, with zero changes to Pi, zero changes to the bridge, and the +payload carried by an envelope until an upstream field exists. + +## Q1 evidence: the hop is real, mid-gate + +In the `allow` run, the model called `park_probe`, the hook raised `ctx.ui.confirm`, and the +client received a real permission request while the tool call sat pending +(`evidence/allow-transcript.jsonl`): + +``` +17:41:23.929 A->C session/request_permission + toolCall: { toolCallId: "pi-ui-8f9a...", kind: "other", status: "pending", + title: "agenta-approval", rawInput: { method: "confirm", ... } } + options: [ {optionId: "yes", kind: "allow_once"}, {optionId: "no", kind: "reject_once"} ] +17:41:23.933 A->C session/update tool_call_update + toolCallId: "call_6JFT..." status: "completed" + content: "EXECUTED park_probe token=TOKEN-ALLOW-a1b2" +17:41:24.761 A->C session/prompt.result stopReason: "end_turn" +``` + +The answer resolved the held dialog, the hook returned allow, and the original `execute` ran +with the original token. Path confirmed end to end: extension -> Pi RPC `extension_ui_request` +-> `pi-acp` `handleExtensionConfirm` -> `conn.requestPermission` +(`pi-acp/dist/index.js:1106-1128`). + +## Q2 evidence: strings natively, everything with the envelope + +Natively the bridge synthesizes the ACP tool call from the dialog: `toolCallId` +`pi-ui-`, `rawInput` `{method, title, message}`. The real gate identity is not there. +The spike's hook therefore packed it into the message as JSON, with a hostile probe string: + +``` +message: {"v":1,"gate":"pi-custom-tool","harness":"pi","toolName":"park_probe", + "toolCallId":"call_6JFT...","input":{"token":"TOKEN-ALLOW-a1b2"}, + "probe":"quotes\"and\\back\\slashes and 日本語 and \n newline"} +``` + +The envelope arrived byte-exact on the client (`evidence/allow-transcript.jsonl`, the +`request_permission` line). So the payload-fidelity gap closes with parsing, not with upstream +work. The caveat that matters for the runner: without envelope parsing, the gate would key as +`toolName="agenta-approval"`, `args={method,title,message}`, which would put the wrong +identity on approval cards, the durable decision map, and permission policy. Parsing the +envelope into the real gate identity is runner work item 1. + +## Q3 evidence: the park is the default, not an engineering feat + +In the `hold` run the client received the permission request at 17:42:21.378, heartbeated for +three minutes without answering, then answered yes: + +``` +17:42:21.378 A->C session/request_permission (held, no answer) + ... 180067 ms pass, no reaper fires, session stays alive ... +17:45:21.454 A->C tool_call_update status: "completed" + content: "EXECUTED park_probe token=TOKEN-PARK-9f9f" +17:45:22.198 A->C session/prompt.result stopReason: "end_turn" +``` + +One uninterrupted `prompt()`, original call, original arguments, `end_turn` only after the +answer. Why no reaper: Pi's RPC dialog helper arms a timeout only when the caller passes +`opts.timeout` or an aborting `opts.signal` (`pi-coding-agent/dist/modes/rpc/rpc-mode.js: +44-80`); the hook passes neither. The relay's `RELAY_TIMEOUT_MS` is never touched on this +path. This inverts the design's framing: Option B has to engineer an unbounded fail-closed +wait into the relay; the dialog path parks out of the box. + +## Q4 evidence: deny, reject, and drop are all fail-closed + +- `deny` (`evidence/deny-transcript.jsonl`): the `no` answer resolved `confirm=false`, the hook + returned a block, the tool call reported failed, `execute` never ran, and the turn ended + cleanly. +- `rejecterr` (`evidence/rejecterr-transcript.jsonl`): throwing from the client's + `requestPermission` handler (the ACP request errors while the daemon lives) also resolved to + `confirm=false`; same block, same clean turn end. +- `drop` (`evidence/drop-transcript.jsonl`): EOF-ing the transport while the request was + pending killed `pi-acp` and the wrapped Pi process cleanly, with nothing executed and no + orphan left. The degradation is exactly tier 2: the session file holds the pending call + (the kill-and-resume experiments proved that flush timing), so a later resume is a faithful + continuation. + +## Q5: the sandbox-agent path, from source + +Verified in the shipped source rather than a live daemon, to keep the running stack +untouched: `acp-http-client@0.4.2` (`dist/index.js:448-452`) forwards the whole +`requestPermission(request)` to the registered handler unchanged, with a fail-closed +no-handler fallback (cancelled). On the runner side, `attachPermissionResponder` +(`services/runner/src/engines/sandbox_agent/acp-interactions.ts`) routes a gate it has no spec +for onto the user-approval path, which is the slice-2 `pauseUserApproval` / +`respondPermission` park machinery. One live daemon run remains as a confidence check before +the build; it is a residual, not an open design question. + +## The most surprising finding + +The hard part of Option B, an unbounded wait that stays fail-closed, is the default behavior +of the dialog path. The relay poll needs timeout surgery to park; `ctx.ui.confirm` with no +options parks indefinitely and resolves to a fail-closed `false` on any cancellation. The +mechanism the design was prepared to build already exists one layer up. + +## What this means for the design + +- Option C is proven and recommended. Two runner work items remain: parse the JSON envelope + into the real gate identity (name, call id, arguments) wherever permission requests are + classified, and switch the in-sandbox extension to raise `ctx.ui.confirm` at the gate + instead of the relay poll (the relay stays for execution). +- Option B stays documented as the fallback: fully in our code, no third-party bridge in the + path, but three code deltas and a new resume verb that C makes unnecessary. +- The cleanup end state: upstream a structured-metadata field to `pi-acp` (maintainer Sergii + Kozak, `svkozak/pi-acp`) so the envelope encoding disappears, or carry a pnpm patch. + +## Caveats and limits + +- Q5 is source-verified, not live-verified; one daemon run is the recorded residual. +- The runs used the codex subscription (`openai-codex/gpt-5.5`) as the model behind Pi; + the gate mechanics are harness-level and model-independent. +- Versions pinned in the protocol: `pi-coding-agent@0.79.4`, `pi-acp@0.0.29`, + `@agentclientprotocol/sdk@0.26.0`. The dialog reaper behavior is version-specific to Pi's + RPC mode helper; re-check on a Pi upgrade. diff --git a/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/spike-extension.js b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/spike-extension.js new file mode 100644 index 0000000000..0fa8e9a213 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/spike-extension.js @@ -0,0 +1,80 @@ +/** + * Option C spike extension. + * + * Registers one custom tool, `park_probe`, and gates it in a `tool_call` hook that + * awaits `ctx.ui.confirm(...)`. Under pi-acp this dialog must surface as an ACP + * `session/request_permission` to the client. The confirm MESSAGE carries a JSON + * envelope so we can measure payload fidelity (the real tool-call id + args do NOT + * ride the ACP request natively; we tunnel them here). + * + * Dependency-free on purpose: a plain ESM factory, so Pi's loader needs nothing to + * resolve. Everything is logged to stderr with an [spike] prefix; the ACP client + * captures the wire side. + */ +function log(msg) { + process.stderr.write(`[spike] ${new Date().toISOString()} ${msg}\n`); +} + +export default function factory(pi) { + log("extension loaded"); + + pi.registerTool({ + name: "park_probe", + label: "park_probe", + description: + "Test tool for the Option C spike. Echoes back the token it was given.", + promptSnippet: "Call park_probe with the requested token.", + promptGuidelines: [ + "When calling park_probe, pass the exact token string the user gave, as the 'token' argument.", + ], + parameters: { + type: "object", + properties: { + token: { type: "string", description: "the token to echo" }, + }, + required: ["token"], + additionalProperties: false, + }, + async execute(toolCallId, params) { + log(`execute park_probe id=${toolCallId} params=${JSON.stringify(params)}`); + const token = params && typeof params === "object" ? params.token : undefined; + return { + content: [{ type: "text", text: `EXECUTED park_probe token=${token}` }], + details: { toolName: "park_probe" }, + }; + }, + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "park_probe") return undefined; + + const envelope = { + v: 1, + gate: "pi-custom-tool", + harness: "pi", + toolName: event.toolName, + toolCallId: event.toolCallId, + input: event.input, + // deliberately awkward chars to test round-trip fidelity through the message field + probe: 'quotes"and\\back\\slashes and 日本語 and \n newline', + }; + const message = JSON.stringify(envelope); + log( + `tool_call hook fired for park_probe id=${event.toolCallId} hasUI=${ + ctx && ctx.hasUI + } mode=${ctx && ctx.mode}; calling ctx.ui.confirm (message ${message.length} bytes)`, + ); + + let confirmed; + try { + confirmed = await ctx.ui.confirm("agenta-approval", message); + } catch (err) { + log(`ctx.ui.confirm threw: ${err && err.message ? err.message : err}`); + return { block: true, reason: "confirm errored" }; + } + log(`ctx.ui.confirm resolved: ${confirmed}`); + + if (confirmed === true) return undefined; // allow -> execute runs + return { block: true, reason: "denied by agenta spike (Option C)" }; + }); +} diff --git a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md new file mode 100644 index 0000000000..43f48afced --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -0,0 +1,19 @@ +# Session keep-alive: open questions + +None of these block slice 1. They refine defaults and edge behavior. Each states a working default so the plan can proceed. Items marked DECIDED are settled; the rest still want a confirmation. + +1. **Idle TTL default. DECIDED: 60 seconds.** How long an idle session waits for the next normal message. Longer means more follow-ups land on the live session; longer also holds idle RAM (about 330 MB per parked Claude session, measured, see item 3). Sixty seconds fits the playground's typical pause between messages. + +2. **Approval TTL default. DECIDED: 5 minutes, and configurable.** How long a session parked on an open approval holds its live process and permission request while it waits for a human. The value is configurable per environment, and Daytona should use a smaller one, because a parked Daytona sandbox costs billed wall-clock time, not just RAM (plan.md Q8). Five minutes is the trade-off: long enough for a human who reads the request and clicks, short enough to keep idle cost low. A human who steps away longer than the window still gets a correct answer, through the cold decision-map path. It changed down from an earlier 10-minute working default for the cost reason. + + Side note for later (not part of this feature): once the durable interactions plane is real, a parked approval will leave two records, a fast in-memory parked session (valid for this TTL) and a durable interaction row (valid indefinitely). The approval TTL then only bounds the fast lane; a late answer settles the durable row and replays cold. The "state later" composition is written up in architecture-notes.md ("Relation to the interactions plane"). + + The kill-and-resume experiments (2026-07-09, `../harness-session-resume/experiments/report.md`) settled what an answer past this TTL can ever be: not an exact resume. The pending `tool_use` survives on the harness's disk, but on load both harnesses settle it as errored and the model re-issues a new call. So past the TTL, the best case is a faithful continuation (with session resume) or today's cold decision-map replay; only the parked live session gives the exact original call. This confirms the TTL is a real correctness boundary, not just a cost knob. + +3. **Pool cap default. DECIDED: about 8, and it is a RAM-budget knob.** This bounds total idle RAM on one replica. Measured cost per parked Claude session is about 336 MB RSS (about 224 MB Pss, shared-adjusted): a three-process tree of the sandbox-agent daemon (~16 MB), the ACP adapter (~82 MB), and the Claude harness (~246 MB, the dominant part). The runner spawns a fresh tree per session; nothing is shared between sessions. A full pool of 8 costs about 2.9 GB RSS on top of the ~250 MB idle baseline. Size the cap to the container's RAM. When the pool is full, the runner evicts the least-recently-used idle session and parks the new one; a busy or awaiting-approval session is never evicted; if nothing idle is evictable, the new session runs unparked (cold on its next turn). So a small cap never blocks a user or fails a turn; it only lowers the cache hit rate. Method and raw figures are in status.md. + +4. **History fingerprint over the pruned array. DECIDED.** The fingerprint is computed over the message array the server actually receives, which is the frontend's pruned array (the frontend drops answer-less assistant turns before sending). A unit test pins this so a future frontend pruning change trips a test instead of silently invalidating continuations. A miss degrades to cold replay, never a wrong continuation. + +5. **Supersede versus reject on a racing second turn.** Working default: supersede. If a second turn arrives while a session is busy (two tabs, or a message sent while the first is still running), destroy the busy session and cold-start the new turn. The alternative is to reject the second turn. Supersede is simpler and honors "never fail a turn." Confirm supersede is acceptable. Reasoning and the "why not a backend lock" analysis are in architecture-notes.md Decision 3. + +6. **Config fingerprint field list.** The plan hashes the config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credential mode, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, draft flag) and excludes the per-turn volatiles (messages, turn id, trace propagation, rotating telemetry headers, secret values). Is any field misclassified? This is the one contract worth a careful pass with the design-interfaces lens before code. Credential rotation is handled separately, by the credential epoch on the park record (architecture-notes.md Decision 7), not by this fingerprint; secret values stay out of the config fingerprint. diff --git a/docs/design/agent-workflows/projects/session-keepalive/plan.md b/docs/design/agent-workflows/projects/session-keepalive/plan.md new file mode 100644 index 0000000000..a7d3540bcb --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/plan.md @@ -0,0 +1,171 @@ +# Session keep-alive: plan + +Read [architecture-notes.md](architecture-notes.md) first. It explains how the runner works today, what keep-alive changes (with before/after examples), and every design decision with its trade-offs. This plan builds on it and does not repeat the deep detail. Provenance, review history, and QA results are in [status.md](status.md). + +- Why this exists: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). +- The complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). + +## What this feature is + +Today the runner destroys the whole agent process tree at the end of every turn. The next message in the same conversation cold-starts a fresh tree and hands the agent a flattened text copy of the conversation, so the agent has no real memory of what it just did. Keep-alive changes one thing: when a turn ends, the runner keeps the tree alive for a short time (a TTL). If the next message arrives inside that window, the runner sends only the new user text to the still-running agent, which still holds its full native memory. If the window has passed, or anything does not match, the runner falls back to today's cold path. Nothing can get worse than today. + +The feature is flag-gated and local-only first. It changes runner code only: no wire change, no SDK change, no frontend change. With the flag off, behavior is byte-identical to today. + +## Q&A: Mahmoud's questions, answered + +Each answer is direct. For the full reasoning behind a decision, the referenced section of architecture-notes.md carries the options and trade-offs. + +### Q1. How would it be implemented? (files, the acquire/run split, the pool) + +Three pieces of work, all in the runner. No new endpoint: keep-alive lives entirely behind the existing `/run` request. The dispatch that runs on every `/run` gains a pool lookup; everything else is internal. + +1. **A new pool file, `services/runner/src/engines/sandbox_agent/session-pool.ts`.** It holds a `Map`. The pool key is the project scope plus the conversation id (`:`), not the caller-supplied `session_id` alone. The project scope comes from the mount-sign response, because no project id rides the `/run` wire; a session with no mount scope is never parked. A `LiveSession` record keeps the live sandbox, the live session handle, the internal tool-server closer, the working directory, the two fingerprints (config and history), a credential epoch, a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), a TTL timer, and one idempotent `destroy()` closure. Full reasoning: architecture-notes.md Decisions 1, 2, and 7. + +2. **A split of `runSandboxAgent` in `services/runner/src/engines/sandbox_agent.ts`** into `acquireEnvironment(request)` (the expensive, session-scoped build, today's lines 328 to 699) and `runTurn(env, request, emit, signal)` (the per-turn work, today's lines 712 to 986). Acquire registers a finalizer per resource as it is built, so a half-built environment cannot leak. The event listeners attach once, for the life of the session, and demux into the active turn (architecture-notes.md Decisions 4 and 5). + +3. **A dispatch wrapper in `services/runner/src/server.ts`.** On a session-owned request with the flag on, it checks the pool. A hit with matching fingerprints and a fresh user message continues the live session. A hit whose new content answers a parked Claude gate resumes the approval. A miss, a mismatch, a busy session, or a dead session runs today's cold path. + +Tests go through the existing seams (`SandboxAgentDeps` and `createAgentServer(run)`), so no live harness is needed for unit tests. + +### Q2. How complex is it really? (honest size per slice, and the genuinely risky part) + +| Slice | Files touched | Rough size | Risk | +|---|---|---|---| +| 1. Keep-alive across normal turns | new `session-pool.ts`; the split in `sandbox_agent.ts`; the dispatch in `server.ts`; tests | 350 to 500 lines | Medium | +| 2. Keep-alive across approval pauses | `pause.ts`; the resume wiring in `sandbox_agent.ts`; interaction ordering; tests | 200 to 300 lines | High on correctness | +| 3. Daytona | remove the `isDaytona` gate; reconcile auto-stop with the TTL; verify cookie-fetch reuse and mounted-cwd survival; tests | small code, real operational risk | Deferred until 1 and 2 have run in real use | + +The map and the timer are the easy parts, a few dozen lines with almost no risk. Two other parts are the real risk, and neither is the pool: + +1. **Deferring teardown (slice 1).** Today the whole cleanup lives in one `finally` block, so it is guaranteed to run on every exit. Keep-alive moves cleanup out of the `finally` and hands it to a timer and a pool, which removes that guarantee. So every teardown trigger has to be re-proven to fire, and the shared `destroy()` has to be idempotent because the sandbox may already be gone. The exact steps that must survive are the ones the 2026-07-06 child-process-leak incident added: a graceful `session/cancel`, then abort the internal tool server, close it, `destroySession`, `destroySandbox`, dispose, unmount the working directory, and remove the temp dirs. Getting this wrong leaks sandboxes and orphaned harness processes, which is the class of bug that incident fixed. See architecture-notes.md Decision 8. + +2. **Routing each event to the right turn (slice 1, and again in slice 2).** Each turn's handlers close over turn-specific state. The naive fix (detach turn N's listeners, attach turn N+1's) has a fatal window: the `sandbox-agent` listener registries are plain sets, an event with no listener is silently dropped, and a permission request with no listener is cancelled. So any gap between detaching and re-attaching is a window where an approval can be cancelled. The design avoids the gap entirely: attach the listeners once, for the life of the session, and demux into a mutable current-turn reference. The risk then is not a timing bug ("did we detach in time") but a routing property ("does every event reach the right turn"), which a fake session tests directly: two sequential turns must each see only their own events, and an event fired between turns must hit the between-turns handler. See architecture-notes.md Decision 5. + +Slice 2's risk is correctness density, not size. It changes the pause path from "destroy the session" to "park it and hold the permission request," and it has to preserve the several existing rules that already interact there (the latch-loser sweep, the orphaned-tool-call sweep, and the "a pause sends no harness reply" contract) while adding a `resume()` step. + +There is also a standing observation, not part of this design: `sandbox_agent.ts` is a long file, and the acquire/run split is a good moment to break it up further. That structural cleanup is recorded as a follow-up in status.md, not folded into this feature's scope. + +### Q3. What does "keep alive" cost while idle? (measured memory and CPU) + +"Alive" means one full process tree stays running between turns, per session: + +``` +runner (the long-lived service) +└─ sandbox-agent daemon (one per session) + └─ ACP adapter subprocess (claude-agent-acp, or pi-acp) + └─ the harness process (the Claude CLI, or pi) +``` + +plus the session object, the internal tool server (its URL is baked into the session, so it must outlive the turn), the working directory, and the mount. This tree is built per session, not shared; the daemon spawns a fresh adapter and harness for every `createSession`. + +While idle, nothing in this tree is executing. There is no prompt running, so CPU is near zero. The cost is memory. These are measured numbers from the dev box, against real parked Claude sessions (method and raw figures in status.md): + +- **Per parked Claude session: about 336 MB RSS (about 224 MB Pss, shared-adjusted).** The harness process dominates (~246 MB), the ACP adapter is ~82 MB, the daemon ~16 MB. Pss is the honest marginal cost because the node and claude processes share library pages. +- **Idle CPU: near zero.** The parked processes block on I/O. The quietest whole-container reading with sessions parked was 0.45%. +- **Baseline idle runner (no sessions): about 250 MB RSS.** +- **A full pool of 8: about 250 MB + 8 x 330 MB = about 2.9 GB RSS** (less on a Pss basis). + +Local keep-alive spends host RAM only, no money. Daytona spends billed wall-clock time (Q8). The pool cap bounds the total; see architecture-notes.md Decision 9 for sizing. + +### Q4. How is it auto-killed, and how are edge cases handled? (TTL, LRU, teardown, SIGTERM, expired approvals) + +Every teardown trigger calls the same one idempotent `destroy(sessionId)`, which runs the full cleanup from Q2. Destroying everything cleanly is the whole safety story, so it is worth being explicit that a leak here is the failure this feature must not introduce. + +Triggers: +- **Idle TTL expiry** (default 60 seconds): the idle timer fires; destroy. +- **Approval TTL expiry** (default 5 minutes, Q on defaults below): the parked-approval timer fires; destroy and abandon the held request. This degrades to today's behavior: the frontend still shows the Approve button, and the click lands on the cold decision-map path. +- **LRU cap** (default about 8): when the pool is full, evict the least-recently-used idle session; never evict a busy or awaiting-approval one; if nothing idle is evictable, run the new session unparked. Parking is best-effort. +- **Fingerprint mismatch**: evict, destroy, run cold. +- **Explicit stop**: `POST /kill` drains the whole pool. +- **Runner shutdown (SIGTERM or SIGINT)**: the shutdown handler drains the pool through `pool.destroyAll()`, timeout-bounded. The `inFlightSandboxes` registry alone is not enough; it only destroys the sandbox and skips the relay, the tool server, `destroySession`, dispose, unmount, and temp dirs. The pool's `destroy()` is the authoritative cleanup. A hard SIGKILL or OOM skips all of it; the process dies with its local children, and the Daytona auto-stop backstop covers the remote sandbox a signal can never reach. +- **Client disconnect**: destroy, do not park. +- **Runtime failures**: a rejected parked promise, a sandbox that died mid-idle (caught by a liveness probe on the next acquire), or a mid-turn continuation failure all evict and fall back to cold; a mid-turn failure retries once cold. + +On Daytona this matters more, because a leaked remote sandbox costs money, not just RAM. The same `destroy()` calls `destroySandbox()`, which deletes the ephemeral remote sandbox on eviction, and the 15-minute auto-stop plus ephemeral auto-delete are the backstop for anything a signal cannot reach. See architecture-notes.md Decisions 8 and 10. + +### Q5. How do sessions connect to keep-alive? (the pool key, and the relation to option 3) + +The pool key is the project scope plus the conversation `session_id`. The frontend mints the `session_id` once per chat tab and sends it on every request; it already rides the wire end to end with no change. The project scope comes from the mount-sign response, because the wire carries no project id (architecture-notes.md Decision 1). So a follow-up message in the same conversation, from the same project, finds the parked session under that key. + +Keep-alive and session resume (option 3) are different memories, and they compose rather than replace each other. Keep-alive is memory within the TTL window, on the same live process: the fast path, costing idle RAM. Session resume is memory across restarts and long gaps: it reloads the harness's own session files after the process is gone, costing a load per turn but zero idle RAM. They form a three-tier fallback on the same `session_id`: a live pool hit, then a session reload, then cold replay (architecture-notes.md "Relation to session resume"). + +### Q6. Before or after option 3 (session resume)? + +Build keep-alive first. Order: slice 1, then slice 2, then session-resume slice A. Keep-alive removes the two production approval failures immediately, it is runner-only with the smallest blast radius (no wire, SDK, frontend, or storage change), it has no upstream dependency (session resume needs a `session/load` bridge that `sandbox-agent` 0.4.2 does not yet expose), and it establishes the fingerprint and skip-flatten seams that session resume reuses. Session resume is still the target for full fidelity across restarts and long gaps; build it next, not first. + +### Q7. How does it relate to human-in-the-loop approvals? + +The human side does not change: the same request reaches the same UI, and the human approves or denies the same way. What changes is what the runner does after the click. Today it destroyed the session on pause, so it cold-starts, replays a flattened transcript, and waits for a fresh harness to re-issue the tool call and match a stored decision keyed on the exact arguments. That re-issue is where both production turns failed (argument drift, task restart). With keep-alive inside the approval TTL, the parked session still holds the open permission request and the suspended `prompt()`, so the runner answers the request and the original call runs with its original arguments. No harness re-issues anything. Both paths stay: the live path inside the TTL, the cold path when the TTL expired or the runner restarted. See architecture-notes.md Decision 6 for the mechanism. + +**Which gates park, and which do not (the important part, covering Claude and Pi).** The runner has four approval-gate mechanisms, and only one leaves the runner holding something it can answer after the turn ends. Slice 2 v1 parks that one and leaves the other three on today's cold path, each for a structural reason. + +| Gate | Harness | How it pauses today | Slice 2 v1 | What staying cold means | +|---|---|---|---|---| +| ACP permission gate | Claude | The harness sends the runner a blocking permission request; the runner holds it as a pending promise, answerable at any later time | **Parks.** The session holds the pending request and the suspended prompt; on a validated resume the runner answers it and the original call runs | This is the live path | +| Custom-tool relay gate | Pi | The tool call blocks inside the sandbox, polling a file, on its own 60-second deadline | **Stays cold.** The block is a file poll inside the sandbox, not a runner-held promise, so there is nothing to answer | Identical to today: the deadline expires, the turn ends paused, the approval resumes through the cold decision map | +| Builtin gate | Pi | A hook inside the Pi process blocks synchronously on the same relay | **Stays cold.** A synchronous in-process block cannot survive a turn boundary | Identical to today | +| Client-tool MCP pause | Claude (client tools) | The runner aborts the in-flight HTTP call; the request is destroyed, not held | **Stays cold.** There is no held request to answer | Identical to today | + +**Why Claude and not Pi, stated plainly.** A gate is parkable only if the runner still holds an answerable handle after the turn ends. Claude's ACP permission request is exactly that: a promise in the runner's own memory, with the harness process kept alive. The Pi gates put the wait inside the sandbox on a bounded file poll, so the runner holds nothing; the client-tool pause actively destroys its request. To make Pi parkable, two things would have to change: the in-sandbox wait would need to be unbounded or resumable instead of a 60-second fail-closed deadline, and the pending decision would have to move into a runner-held handle the way the Claude request already is. That is a relay redesign, out of scope for v1, recorded as future work. Until then the Pi consequence is spelled out: Pi approvals keep working through the cold decision-map replay, with the same drift risk that path has always had. See architecture-notes.md Decision 6. + +**Whose context runs the resumed tool, and why.** The original turn's baked environment executes the tool (its signed mount credentials, resolved secrets, callback auth, harness env, and sandbox), because the harness process already holds all of that and the resume cannot re-bake it. The new turn owns only streaming and tracing: its trace run records the resumed events, its emitter streams them, and its hooks resolve the durable interaction row. The credential epoch bounds staleness: if the baked credentials expired or rotated before the resume arrived, the session evicts and the cold path re-bakes everything fresh (architecture-notes.md Decision 7). One new ordering rule is needed: a later turn's stale-interaction sweep must not cancel the interaction the runner is about to resolve on the same session. + +**The "state later" story (the durable interactions plane).** Today an approval travels inside the conversation (the messages plane). A newer interactions plane (a durable `session_interactions` table plus endpoints) is the future home for approvals answered hours later, from any surface, without a chat held open. Keep-alive is the fast, in-memory tier of that same idea, and the interaction row is the durable tier. A pending approval leaves both: a parked live session valid for the approval TTL, and a durable row valid indefinitely. Whoever answers first wins: a quick click resumes the live call; a late answer settles the row and replays cold. The resolver that reconciles the two planes is deferred; keep-alive makes the fast lane real and leaves the row untouched. Full detail: architecture-notes.md "Relation to the interactions plane." + +### Q8. Daytona (slice 3): cost, and where the difference lives + +Implement Daytona only after slices 1 and 2 have run locally in real use. The reason is cost and remote failure modes, not code complexity. + +**Where the difference lives.** The pool logic is provider-neutral. Local and Daytona use the same `SandboxAgent` and `Session` interface; only the provider construction differs in `provider.ts`. There is no Daytona-specific pool adapter. The one reconciliation is the sandbox's own 15-minute idle auto-stop: a parked session must not let the remote sandbox stop out from under it, so with a longer TTL the auto-stop is raised or disabled while parked, and the pool's TTL becomes the primary reaper. That lives in `provider.ts` and the TTL config (architecture-notes.md Decision 10). + +**Cost.** Daytona bills per second for the resources a sandbox consumes: about $0.0504 per vCPU-hour, $0.0162 per GiB-RAM-hour, and a negligible per-GiB-disk-hour. A default sandbox (1 vCPU, 1 GiB RAM, 3 GiB disk) runs at about $0.067 per hour. Two facts shape the design: + +- A **stopped** sandbox bills only for disk, about $0.0003 per hour, roughly 200 times cheaper than running. So keep-alive's Daytona cost is the cost of keeping a sandbox running instead of stopping it. +- Cold start is sub-100 milliseconds, so cold-starting the next turn costs almost nothing in dollars. Its only cost is latency. + +Back-of-envelope, a 5-minute idle TTL per conversation on the default size costs about $0.0056 (half a cent) per conversation. At 100 active users at roughly 10 conversations a day, that is about $168 a month; at 1,000 users, about $1,680 a month. The 24/7 ceiling per user is about $49 a month, which is why the auto-stop and a short TTL matter. The dominant lever is not the TTL length; it is making sure sandboxes actually stop rather than linger running. This is why Daytona uses a shorter TTL than local, and why slice 3 is gated behind local success. + +## Could a minimal version work in about an hour? + +There are two different "minimal" versions, with different honest answers. + +**A one-hour spike: yes, and it is worth doing.** The smallest thing that proves the idea is to drive one `sandbox-agent` session with two sequential `prompt()` calls in a test. Turn 1 runs a tool. Turn 2 asks "what did you just do?". Show that the harness remembers turn 1 natively and that the event stream re-attaches cleanly. That is about 50 lines and needs no refactor; it de-risks slice 1. A second spike does the same for slice 2: raise a gate, hold the permission request for a minute, then answer it, and show the original prompt continues with the original arguments. Both are legitimate stepping stones. + +**A one-hour shippable feature: no, and trying it would create the complexity we want to avoid.** The irreducible core of slice 1 is not the map and the timer. It is deferring teardown and routing events to the right turn (Q2). A one-hour hack would either skip the idempotent shared `destroy()` (which reintroduces the 2026-07-06 process-leak class) or skip the event routing (which double-fires events and corrupts the decision map on turn two). Both are the failure classes this feature exists to remove. So a one-hour hack is not a smaller version of the feature; it is a broken one. Spend the first hour on the spike, then build slice 1 properly. + +## Flags and defaults + +- `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off). +- `AGENTA_RUNNER_SESSION_TTL_MS` (default 60000, the idle TTL). +- `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (default 300000, the approval TTL). +- `AGENTA_RUNNER_SESSION_POOL_MAX` (default about 8). + +The approval TTL default is 5 minutes, not 10. A parked approval holds a live process (and, on Daytona, billed time), so the default trades a little cache lifetime for lower idle cost; a human who steps away longer than 5 minutes still gets a correct answer through the cold path. The value is configurable, and Daytona should use a smaller one. These flags go on the runner's config module, not read ad hoc from `process.env`. Requests without a `session_id` never park. + +## Failure modes (detect, degrade, never fail the turn) + +| Failure | Detection | Fallback | +|---|---|---| +| Sandbox dies mid-idle | parked promise rejection; liveness probe on acquire | evict, cold replay | +| Request after TTL | pool miss | cold replay | +| Two turns race one session | busy flag (single-threaded check-and-set) | supersede: destroy, cold-start the new turn | +| Continuation throws mid-turn | try/catch in runTurn | destroy; retry once cold | +| Client disconnects | session-owned runs already survive | on abort: destroy, do not park | +| Approval reply after approval-TTL | pool miss | cold replay plus the existing decision-map path | +| Config changed mid-conversation | config fingerprint mismatch | evict, cold-start with the new config | +| Multi-replica miss (future) | pool miss | cold replay; later route via session affinity | + +## Verification plan + +- Before slice 1: run the two-prompt spike to confirm native memory and clean event routing across turns. +- Before slice 2: run the hold-a-permission spike to confirm the parked prompt continues with the original arguments. +- Per slice: unit tests through `SandboxAgentDeps` and `createAgentServer(run)`, then a live check on the dev box against a real playground conversation, with the flag on and off, confirming flag-off is byte-identical. +- The history fingerprint is pinned by a unit test to the exact (pruned) message array the server receives, so a future frontend pruning change trips a test instead of silently causing cold-replay misses. + +## Out of scope + +- No wire, SDK, or frontend change. +- No storage change (that is session resume, option 3). +- No multi-replica routing (a pool miss degrades to cold; affinity routing is future work). +- No Pi-gate parking (Q7); that needs a relay redesign, recorded as future work. +- The option 1 text-replay fixes are a separate track and land regardless. diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md new file mode 100644 index 0000000000..40c96bca45 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -0,0 +1,170 @@ +# Session keep-alive: status + +Source of truth for progress. Keep this current. + +## Current state (2026-07-08) + +- Phase: implementation, PRs in review. Mahmoud approved the plan subject to the Codex xhigh review findings; all seven findings, plus a confirmation-pass correction, are folded into plan.md and architecture-notes.md. +- Codex xhigh confirmation pass over the amended plan: 5 of 7 folds confirmed outright; 2 material corrections found and folded the same day (the pool-key project scope comes from the mount-sign response since no project id rides the wire, with a hard no-mount-no-park rule; the credential epoch hashes resolved secret VALUES process-locally since no version identity exists on the wire), plus one stale park-mode sentence corrected to match the v1 gate scope. +- Slice 1 (`feat/session-keepalive-pool`) and slice 2 (`feat/session-keepalive-approvals`, stacked on slice 1) are being implemented as draft PRs based on `big-agents`. +- Research: [architecture-notes.md](architecture-notes.md) verified against the current runner code. See "Drift check" below. + +## Documentation rewrite (2026-07-08) + +Mahmoud reviewed PR #5153 and asked for a substantial rewrite for clarity, context, and reasoning: current-state before change, before/after flows with examples, every decision as problem/options/trade-offs/choice/why, plain language with no meta-provenance in the main text, and a solution story that covers both Claude and Pi. This pass rewrote: + +- `architecture-notes.md` restructured into three parts (how the runner works today, what keep-alive changes with before/after examples, the design decisions each with trade-offs), plus a relations section (session resume + restart consequence, the interactions plane "state later" story, the memory-leak honest answer). +- `plan.md` Q&A deepened with measured numbers, the Daytona cost estimate, the Pi-vs-Claude approval story, the restart consequence, and the interactions-plane composition; all review-provenance wording ("amended after review", "confirmation pass", "Codex finding") removed from the main text and kept here. +- `open-questions.md` folded in Mahmoud's answers (idle TTL 60s lgtm, approval TTL 5 min and configurable, pool cap sized from measured RAM). +- The provenance of the amendments themselves lives only here, per Mahmoud's rule that meta stays out of the design text. + +## Second review round (2026-07-08) + +Mahmoud did a second pass on PR #5153 (13 inline comments on architecture-notes.md, plus "please address"). This round's changes, all provenance kept here rather than in the design text: + +- **Decision 1 (project scope home).** Added the honest analysis of whether taking `project_id` from the mount mixes responsibilities. It does, partly. Verified in code: the live `/run` wire never populates the nominal `projectId` field and the runner never reads it, so the mount-sign response is the only server-verified scope the runner holds today; but the project id already exists one layer up (`RuntimeAuthContext.project_id`, from request state). Recorded the clean home (stamp it into `runContext`) as follow-up 5. +- **Decision 2 (config change).** Corrected the overstated "a running harness cannot be reconfigured in place." It is not impossible, just not built. v1 keeps evict-and-cold (option C); the in-place path is designed in `followups/in-place-reconfiguration/`. +- **Decision 2 (observability).** Added the warm-vs-cold visibility section: a silent all-cold regression is the real risk, so surface the path as a trace attribute (`ag.keepalive.path`) and optionally a stream meta event. Recorded as follow-up 6. +- **Decision 3 (stop button + supersede).** Documented the current stop-button reality (session-owned runs ignore the client disconnect; the harness runs to completion in the background; there is no per-turn cancel). Added supersede option (c), abort-the-turn-and-continue-on-the-same-session, as the better end state paired with a real per-turn stop. Recorded as follow-up 7. +- **Decision 3 (why not the backend lock).** Rewrote using verified code: the distributed Redis alive-lock (`api/oss/src/dbs/redis/sessions/locks.py`) already exists and the runner cooperates with it over the HTTP heartbeat; the busy flag is not a reimplementation, it guards an in-process pool of live warmed sandbox handles a Redis key cannot represent. Multi-replica layering recorded. +- **Decision 5.** Expanded with the concrete current-turn-sink shape and a two-turns-plus-stray-event worked example, per the request for more architectural detail. +- **Decision 6.** Removed the meta sentence ("the decision Mahmoud asked to be rewritten"); noted `RELAY_TIMEOUT_MS` is env-configurable and that raising it does not buy parkability; clarified that the park-mode-changes section is the Claude ACP permission gate, not the client-tool MCP pause; pointed the "future path" at `followups/parkable-gates/`. +- **Decision 9.** Removed the meta sentence ("Mahmoud asked for real numbers"). +- **Decision 10 (Daytona).** Verified in code: the runner sets no cpu/memory/disk, so the sandbox comes up at the Daytona account default spec (the cost math assumes that spec); the image is the custom `agenta-sandbox-pi` snapshot; the 15-minute auto-stop is explicitly set (`DEFAULT_DAYTONA_AUTOSTOP_MINUTES`, tunable via `DAYTONA_AUTOSTOP`), overriding the upstream default of off. +- **Two new follow-up designs written and added to this PR**, both of the same standard as the main docs: `followups/parkable-gates/` (make the Pi relay, Pi builtin, and client-tool MCP gates parkable; recommends inverting the Pi relay to a runner-held handle and holding the client-tool MCP socket open) and `followups/in-place-reconfiguration/` (change a live session's config without a respawn). + +## Invariant reframe + kill-and-resume experiments (2026-07-09) + +Mahmoud reframed the parkable-gates follow-up in discussion: judge it by one invariant (whether +an approval is answered warm or cold, the sequence of LLM API calls should be identical: call N +ends with `tool_use`, call N+1 appends the real `tool_result`, nothing regenerated), not by +connection-keeping. Two experiments then tested whether a cold restart can meet that invariant +from the harness's own session file. Protocol and results: +`../harness-session-resume/experiments/` (protocol.md, report.md), run live on the dev box for +Claude Code (CLI `--resume` and ACP `session/load` with `_meta.claudeCode.options.resume`, +wire-verified through a logging proxy) and static-from-source for Pi. + +Verdicts: rubric B everywhere. The pending `tool_use` survives `kill -9` on disk in both +harnesses (per-message flush), but no load path answers it: Claude ACP settles it with a +synthetic error `tool_result` and the model re-issues a NEW id; Claude CLI abandons it as a +no-op; Pi injects a synthetic "No result provided" error result and re-issues. Consequence, +folded into `followups/parkable-gates/`: warm parking is the only byte-exact tier; harness +session resume is faithful-context continuation with re-issue drift; the durable-decision cold +path stays the answer past the warm TTL. The parkable-gates design was rewritten around the +invariant the same day, and its ownership section now states that the Pi relay inversion and +client-tool hold-open must land on top of (or inside) JP's backend warm-session move and +harness-session-resume work. + +## Review round on the parkable-gates rewrite (2026-07-09, later the same day) + +Mahmoud left four inline comments on the rewritten design (PR #5153). Addressing them surfaced +two factual corrections, both verified in code and folded into the design text: + +- **The Pi pause is destroy-first, not timeout-first.** On an ask, the relay watcher fires the + pause immediately and the pause controller destroys the Pi session + (`sandbox_agent.ts:1135-1157, 1272-1293`); only the Claude ACP hook records the + `parkedApproval` that exempts a session. The in-sandbox poll dies of teardown, not of its + 60-second deadline. The earlier text implied the poll spins out its deadline. The design's + "why keep-alive does not park it today" now names the three Claude-shaped code pieces + (park decision, resume verb, poll deadline), which is also the direct answer to Mahmoud's + "why does today's design not work while warm" question, and frames Option B as exactly those + three deltas (his hold-while-warm, write-the-file-on-approval model). +- **Option C is not blocked on Pi upstream work.** Pi extensions get `ctx.ui.confirm` + (dialog-capable in RPC mode), and the `pi-acp` bridge already translates that dialog into a + real ACP `session/request_permission` (`pi-acp/dist/index.js:1106-1128`). `pi-acp` is a + third-party MIT adapter by Sergii Kozak (svkozak/pi-acp, 0.0.29 bundled); Pi itself is Mario + Zechner's. The genuine gaps are payload fidelity (the bridge forwards dialog fields, not the + gated call's id/args) and an unproven end-to-end hop. The recommendation changed from + "B ships, C is a far north star" to "spike C's hop first; C if the payload survives, else B." + +The round also added a per-gate warm/cold pairing section ("The cold path: every gate, when +the answer comes after the park is gone") answering the how-does-cold-work comment, and a +turn-versus-prompt clarification (no new prompt is issued on a warm resume; the new `/run` +request only transports the decision and adopts the resumed stream). + +## Option C spike (2026-07-09, evening): PROVEN, recommendation flipped to C + +The spike proposed in the review round ran the same evening against a real Pi session under +the real `pi-acp` bridge (five scenarios: allow, deny, 3-minute hold, transport drop, clean +reject). Evidence lives at `followups/parkable-gates/spike-option-c/` (protocol.md, report.md, +the spike extension, the ACP client, raw wire transcripts under `evidence/`). Results: +`ctx.ui.confirm` from a `tool_call` hook surfaces as a real ACP `session/request_permission` +mid-gate; a JSON envelope through the dialog message round-trips byte-exact; the held gate +survived 180067 ms with no reaper and the late allow ran the original call with its original +arguments in one uninterrupted `prompt()`; deny/reject/drop are all fail-closed; the +sandbox-agent leg is source-verified (one live daemon run recorded as the residual). Most +surprising: the unbounded fail-closed wait Option B would have to engineer into the relay is +the dialog path's default behavior. The design now recommends Option C, shipping with the +envelope (two runner work items: envelope parsing into the real gate identity, and switching +the in-sandbox extension to raise the dialog at the gate); Option B stays documented as the +not-chosen fallback. + +## Measured costs and mechanism research (2026-07-08) + +Recorded here as the source for the numbers now cited in the design docs. + +**Per-session memory and CPU (measured on the Hetzner dev box, inside `agenta-claude-sub-sidecar`, `AGENTA_RUNNER_SESSION_KEEPALIVE=1`, against real parked Claude sessions).** Method: the image has no `ps`, so measurements read `/proc//status` (VmRSS) and `/proc//smaps_rollup` (Pss) directly, plus `docker stats`. Real playground traffic live-parked sessions (a bare self-managed `/run` carries no mount project scope and runs cold by design, so the app's own traffic was the parking source). + +- Per parked Claude session: sandbox-agent daemon ~15.7 MB RSS / ~11.3 MB Pss; ACP adapter (`@zed-industries/claude-agent-acp`) ~82.4 MB / ~33.4 MB; Claude CLI ~246 MB / ~184.5 MB. Total ~336 MB RSS / ~224 MB Pss. Held stable across a 40s+ parked window and across 3 concurrent sessions. +- Baseline idle runner (no sessions): ~250 MB RSS / ~156 MB Pss. +- Idle CPU while parked: 0.45% quietest; 2.4 to 7% under light real traffic (node event loops and the esbuild watcher, not the parked sessions, which block on I/O at ~0%). +- `docker stats` MEM is a poor per-session signal here (2.5 GiB with zero sessions, page cache; 1.1 to 1.25 GiB with 3 to 7 sessions) because the cgroup counts shared text once while per-process RSS counts it repeatedly. Pss (~224 MB/session) is the honest marginal figure. +- Container was restored: running, `AGENTA_RUNNER_SESSION_KEEPALIVE=1`, health ok, app still pointed at it. + +**Process model.** The runner spawns a fresh three-process tree per session (sandbox-agent daemon per `SandboxAgent`, one ACP adapter per agent-connection, one harness under it), not a shared pool. Confirmed against `sandbox-agent` 0.4.2 (`node_modules/sandbox-agent/dist/providers/local.js` spawns one daemon per instance; `pi-acp`/`claude-agent-acp` spawn the harness). + +**Approval gates.** Only the Claude ACP permission gate leaves the runner holding an answerable promise (`pendingPermissionRequests` map plus the suspended `prompt()` held open by the disabled undici timeout in `acp-fetch.ts`). Pi custom-tool and builtin gates block on an in-sandbox file poll with a 60s `RELAY_TIMEOUT_MS` deadline (nothing runner-held). The client-tool MCP pause aborts its HTTP request (`tool-mcp-http.ts`, nothing held). This is the code basis for slice 2 parking Claude only. + +**Daytona billing.** Per-second, per-resource: ~$0.0504/vCPU-hr, ~$0.0162/GiB-RAM-hr, ~$0.000108/GiB-disk-hr. Default sandbox 1 vCPU / 1 GiB / 3 GiB ~ $0.067/hr running; stopped bills disk only ~$0.0003/hr (~200x cheaper); cold start sub-90ms (negligible dollar cost). 5-min TTL ~ $0.0056/conversation at default size; ~$168/mo at 100 users x 10 conv/day; 24/7 ceiling ~$49/user/mo. Sources: Daytona pricing/billing/docs pages, Northflank and Blaxel 2026 comparisons (exact per-unit rates from the comparisons, consistent with Daytona's own ~$0.067/hr statement; confirm against a live invoice before a contract-grade estimate). + +**Memory leak (2026-07-06 incident).** Killing the daemon does not cascade to the ACP adapter it spawned; the orphan reparents to PID 1. Fixed by sending `session/cancel` before `destroySandbox` (teardown step 4). Residual: a hard SIGKILL/OOM of the runner skips teardown and still leaks. The pool is neutral-to-slightly-helpful on graceful paths (all teardown routes through the same idempotent `destroy()`), slightly worse on the hard-kill path (more trees alive at once). The root fix is an OS-level process-group kill / reaper that does not depend on the `finally` (see follow-ups). + +## Follow-ups recorded (out of scope for this design) + +1. **Lower the implemented approval-TTL default to 5 minutes. DONE (2026-07-09, merged as PR #5178, lane `fix/keepalive-approval-ttl-default`).** The code default is now `DEFAULT_APPROVAL_TTL_MS = 300_000` in `session-pool.ts`; the doc and comment mentions were swept to match (`running-the-agent.md`, `server.ts`). The env var `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` still overrides. +2. **`sandbox_agent.ts` structural cleanup.** The file is long; the acquire/run split is a good moment to break it into smaller modules. A structural refactor task, not part of this feature. +3. **OS-level orphan-process backstop.** A process-group kill or reaper for the daemon-adapter-harness tree that survives a runner SIGKILL/OOM, so a hard kill cannot leak parked trees. Addresses the residual 2026-07-06 leak class, independent of keep-alive. +4. **Frontend "setting up sandbox" phase.** The `/run` stream is silent during the acquire phase (sign mount, start sandbox, create session) before any agent event. A setup-phase event would let the frontend show "setting up" instead of an unexplained wait. Not designed here; a small side-note improvement worth filing. +5. **Move the pool's project scope off the mount and into `runContext`. IMPLEMENTED, parked as draft PR #5180 (2026-07-09).** The service stamps `runContext.project.id` server-side; the pool key prefers it and falls back to the mount's owning project id (`session-pool.ts:345-376`). Parked as a draft rather than merged because the backend warm-session move reshapes the same code; land or fold it there. Decouples the pool key from the mount signer (Decision 1). +6. **A warm-vs-cold run signal for the inspector.** Emit the keep-alive path taken on each run as a trace attribute (`ag.keepalive.path = hit | miss | park | cold`), so a silent regression where every run went cold is a query instead of an invisible slowdown. A stream meta event for a live "continuing vs starting fresh" indicator is a later nicety on the same surface as follow-up 4. The trace attribute should land with slice 1 (Decision 2). +7. **A real per-turn stop, and supersede-without-cold.** Today "stop" only drops the client stream; a session-owned run keeps executing in the background, and a superseding second turn cold-starts. A per-turn cancel that aborts the in-flight `prompt()` and keeps the session would make "stop" actually stop and let a superseding turn continue on the live session instead of going cold. One piece of work; the abort machinery exists but is not wired to a cancel call, and session-clean-after-abort is unproven (Decision 3). + +## Decisions made + +- Build order: keep-alive slice 1, then slice 2, then session-resume slice A. Keep-alive before session resume (see plan.md Q6). +- Local only first; Daytona (slice 3) only after slices 1 and 2 have run in real use with no problems (plan.md Q8). +- Flag-gated, default off. Flag off is byte-identical behavior. +- Pool key is `:` (project-scoped; the conversation id already rides the wire). Parks carry a credential epoch and evict on expiry or rotation. +- Slice 2 v1 parks Claude ACP permission gates only; Pi relay gates, Pi builtin gates, and client-tool MCP pauses stay cold (plan.md Q7 scope table), asserted by tests. +- Listeners attach once per session and demux into the active turn's sink; no per-turn detach/attach (drop/cancel window). +- The pool owns a complete idempotent destroy() per session, built incrementally in acquireEnvironment; the shutdown path drains the pool through it (`inFlightSandboxes` alone only destroys the sandbox). +- A resumed approval executes with the original turn's baked environment; the new turn owns streaming and tracing. +- The debug-local-deployment live loop (implement-feature Phase 3) is EXPLICITLY DEFERRED by Mahmoud; it is the recorded next step after PR review. + +## Open questions + +See [open-questions.md](open-questions.md). None blocks starting slice 1; they refine defaults and edge behavior. + +## Drift check: architecture-notes.md vs current code (2026-07-07) + +Verified against `services/runner/src`. The notes are accurate. Details: + +- Confirmed: `runSandboxAgent` at `sandbox_agent.ts:321-1048`; the unconditional teardown `finally` at 1004-1047; the pause controller destroying the session (`pause.ts:24-29`, callback at `sandbox_agent.ts:737-747`); the prompt/pause race at 899-912; `shouldSuppressPausedToolCallUpdate` at 180; the `onResolveInteraction` hook at 850; `cancelStaleInteractions` at `server.ts:275`; session-owned runs surviving disconnect at `server.ts:237-246`; the SIGTERM handler and `destroyInFlightSandboxes`. +- Confirmed: `sandbox-agent` is 0.4.2. `Session.prompt`, `Session.onEvent` (returns `() => void`), `Session.onPermissionRequest` (returns `() => void`), `Session.respondPermission`, and the daemon's private `pendingPermissionRequests` all exist in the package types. `resumeSession` exists and is the lossy text-replay one (consistent with the report's Q4). +- Confirmed: `request.sessionId` at `protocol.ts:385`, resolved by `resolveRunSessionId` at 561. + +Minor items to note (not blockers): + +1. **Citation nudge.** The notes cite `sessions/alive.ts:32-36` for the `owner:session` affinity keys. Those lines are the `REPLICA_ID` constant. The affinity key is described in the file's header comment (lines 10-11, 30-31) and is driven by `REPLICA_ID`. The claim is correct; the line span points at the const, not the key literal. +2. **Load-bearing nuance for the risk estimate.** The notes say the listeners are "re-attachable ... detach the previous turn's" as if the seam is ready. The package supports it (both listeners return an unsubscribe function), but the current runner code discards those return values (`sandbox_agent.ts:749`, `acp-interactions.ts:51`). So capturing and calling the unsubscribe functions is net-new work, and it is the crux of the slice-1 risk. This is reflected in plan.md Q2. No correction to the notes is needed; it is an emphasis for whoever implements. + +## Live QA notes (2026-07-08, dev box) + +- Slice 1 verified end to end with the flag on: turn 1 cold 25.61 s, turn 2 hit-continue 3.12 s, TTL expiry at 60 s, post-expiry cold replay 22.6 s and still correct, credentials-mismatch eviction correct. Playground and programmatic paths both green. Observed log lines: `[keepalive] miss`, `park ... ttl=60000ms state=idle poolSize=1`, `hit-continue`, `expire ... (TTL 60000ms)`, `evict ... reason=expire`. +- Deployment footgun found during that test (NOT caused by any keepalive lane; `git diff origin/big-agents -- api/**/oss000000006*` is empty and no keepalive lane touches api/): the mounts migration `oss000000006_add_mounts.py` on big-agents now contains a `meta` column, but a dev DB that ran an OLDER revision of that same migration id never gets the column (alembic does not re-apply an applied migration), so mount signing 500s with "column meta of relation mounts does not exist". Keep-alive then silently runs all-cold BY DESIGN (no mount scope means never park), which masks the breakage. Lesson: never edit an already-applied migration; ship a new migration instead. The fix belongs to whoever edited oss000000006 on big-agents. Keep-alive QA must confirm mount signing works first: a `[keepalive] park` line proves it; an all-cold run with no park lines is the tell. + +## Next steps + +1. Land slice 1 (`feat/session-keepalive-pool`, PR #5156) and slice 2 (`feat/session-keepalive-approvals`) as draft PRs; Mahmoud does the final review on the PRs. +2. After review: run the deferred live-deployment loop (debug-local-deployment) for slice 2 (approval parking) with the flag on and off; slice 1's live loop already ran (see QA notes above). +3. Then consider Daytona (slice 3) and session resume (option 3) per the recorded order.