From 4b37122a98028c55a4f41fbce2172c9f1a69f77e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 7 Jul 2026 23:19:50 +0200 Subject: [PATCH 01/13] docs(design): session keep-alive plan (TTL live sessions in the runner) Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../projects/session-keepalive/README.md | 20 ++ .../session-keepalive/architecture-notes.md | 70 +++++++ .../projects/session-keepalive/context.md | 33 +++ .../session-keepalive/open-questions.md | 15 ++ .../projects/session-keepalive/plan.md | 192 ++++++++++++++++++ .../projects/session-keepalive/status.md | 40 ++++ 6 files changed, 370 insertions(+) create mode 100644 docs/design/agent-workflows/projects/session-keepalive/README.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/context.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/open-questions.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/plan.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/status.md 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..f9f629a3ec --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/README.md @@ -0,0 +1,20 @@ +# 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. +- [plan.md](plan.md) — the plan. Contains the Q&A section answering Mahmoud's questions, the "could a minimal version work in an hour?" answer, the slice sizes and risks, flags, failure modes, and the verification plan. +- [status.md](status.md) — source of truth for progress, decisions, and the drift check of the research against current code. +- [open-questions.md](open-questions.md) — decisions that refine defaults and edge behavior; none blocks slice 1. +- [architecture-notes.md](architecture-notes.md) — the code-grounded research the plan builds on. Read this for the exact line references and the full design. + +## Start here + +Read context.md, then plan.md. The plan's Q&A section and the one-hour assessment are the parts Mahmoud asked for. + +## 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..e873c4f6d2 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -0,0 +1,70 @@ +# Session keep-alive: architecture research notes + +Input for the plan-feature pass. Produced 2026-07-07 from a code-grounded design review of services/runner. Source analysis: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). + +## The idea in one paragraph + +Today the runner destroys the sandbox and harness session at the end of every turn. Keep-alive means: when a turn finishes, keep the session alive for a short time (a TTL, for example 60 seconds). If the next user message arrives inside that window, continue the same live session with just the new text. The harness keeps its own full memory (tool calls, results, thinking) because the process never died. If the window expires or anything mismatches, fall back to today's cold replay. Nothing can get worse than today. + +## Verified grounding + +- The runner is a long-lived Node daemon on :8765 (`src/server.ts:491`), one replica in every deployed topology (compose and Helm default). Per-process state already exists (`inFlightSandboxes` set, replica id, `owner:session:` affinity keys in `src/sessions/alive.ts:32-36`). An in-memory `Map` pool is viable today. +- The conversation key already rides the wire end to end: FE `agentRequest.ts:303` sends `session_id`, SDK `handler.py:253` forwards it, runner `protocol.ts:386` receives it. No wire change needed. +- `runSandboxAgent` (`src/engines/sandbox_agent.ts:321-1047`) builds sandbox, session, relay, prompt per request; the `finally` (1004-1047) unconditionally destroys everything. On approval pause, `PendingApprovalPauseController.pause()` (`pause.ts:24-29`) also destroys the session. +- The `sandbox-agent` package's `Session` object supports repeated `prompt()` calls, re-attachable `onEvent`/`onPermissionRequest` listeners (they return unsubscribe functions), and `respondPermission(permissionId, reply)` callable at any later time (pending requests park in `SandboxAgent.pendingPermissionRequests`). Nothing forces one prompt per session. +- The CLI path (`src/cli.ts`, local SDK adapter) is process-per-request; keep-alive is inert there by construction. + +## Design + +### Pool and keying +- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `request.sessionId`. States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. +- Requests without a `sessionId` never park (non-playground callers keep today's semantics). + +### Continuation versus cold decision +Each parked session records two fingerprints: +1. `configFingerprint`: canonical-JSON hash over config-bearing request fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, systemPrompt, appendSystemPrompt, tools, skills, customTools, mcpServers, toolCallback.endpoint, permissions, sandboxPermission, harnessFiles, workflow revision id/version, is_draft). Exclude per-turn volatiles (messages, turnId, trace propagation, telemetry headers which rotate ~15 min, secret values). +2. `historyFingerprint`: ordered prior user-message texts plus ordered tool-call ids (both survive the egress/ingress round trip byte-stable), plus `promptCount`. + +On an incoming request with a pool hit: +- Continue (normal): fingerprints match, tail is a fresh user message -> `session.prompt(newUserText)`. No `buildTurnText`. +- Continue (approval resume): fingerprints match, a parked pending-permission id exists, the new content is an approval envelope whose `toolCallId` matches the parked gate -> answer the parked RPC (below). +- Cold: anything else (mismatch, miss, dead, busy). Evict, destroy, run today's path. A validation failure must never fail the turn. + +### Turn-flow refactor +Split `runSandboxAgent` into: +- `acquireEnvironment(request)`: mount signing through `createSession` + MCP wiring (today lines 328-699). Session-scoped: sandbox handle, session handle, internal tool-MCP server (its URL is baked into sessionInit, so it must live as long as the session), relay dir, mounted cwd, workspace, skills dirs. +- `runTurn(env, request, emit, signal)`: per-turn otel run, fresh `PendingApprovalLatch`, fresh `ConversationDecisions`, re-attach `onEvent`/`onPermissionRequest` (detach the previous turn's), restart toolRelay, prompt, resolve usage, flush otel. + +### The approval interaction (the big win) +F-040 (docs/design/agent-workflows/projects/qa/f040-park-terminal-plan.md) destroyed the session on pause for three reasons: (a) the HTTP turn must end (Claude never resolves `prompt()` on an unanswered gate), (b) the sandbox would leak if the turn never ended, (c) the package blocks manual `session/cancel`, so destroy was the only clean cancel. A TTL park invalidates (b) and (c) and preserves (a): the turn still ends with `stopReason: "paused"` (the existing race at `sandbox_agent.ts:899-912` still works), but the `LiveSession` parks holding the still-pending `prompt()` promise and the unanswered permission id. + +On approval: `respondPermission(parkedId, "once" | "reject")` -> the original prompt promise continues -> the new turn's emitter streams the remaining events -> the tool executes with its original byte-exact arguments. This removes the argument-drift and task-restart failure classes at the root. The exact-args decision-map machinery stays untouched as the cold fallback (approval TTL expired, restart, etc.). + +Required changes in park mode: the pause controller must not fire the destroy callback (inject a park callback); do not abort `mcpAbort` (a paused client tool's loopback call must stay in flight); do not settle the paused call with `TOOL_NOT_EXECUTED_PAUSED` (it will actually run); add `resume()` clearing `pausedToolCallIds` so post-resume `tool_call_update` frames stream again (`shouldSuppressPausedToolCallUpdate`, `sandbox_agent.ts:180`). The "a pause sends NO harness reply" contract (`acp-interactions.ts:68-72`) is unchanged; the reply just arrives later on the same session. The interactions plane needs `resolveInteraction` for the parked token on resume (hook exists at `sandbox_agent.ts:850`), ordered against `cancelStaleInteractions` (`server.ts:275`). + +Note: `acp-fetch.ts` already disables undici header/body timeouts specifically so a paused HITL turn's held ACP connection survives human-timescale delays. The holding infrastructure exists. + +### Lifecycle and safety +- Flags: `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off), `AGENTA_RUNNER_SESSION_TTL_MS` (60000), `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (600000), `AGENTA_RUNNER_SESSION_POOL_MAX` (~8). +- Eviction: LRU on idle entries when full; never evict busy. Parking is best-effort; pool full -> tear down as today. +- Teardown triggers: TTL expiry, fingerprint mismatch, explicit stop (`POST /kill` drains the pool), runner shutdown (keep parked sandboxes in `inFlightSandboxes` so the SIGTERM handler reaps them), rejected parked prompt promise. +- Idle cost: local = host RAM only (a few hundred MB per session: daemon + adapter + harness processes; the 2026-07-06 child-process-leak notes in the finally block list exactly what to track). Daytona = billed idle time; existing backstops (15-min auto-stop, ephemeral auto-delete, `provider.ts:41-98`) still reap leaks. Enable local-only first. + +### 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 new turn | +| Continuation throws mid-turn | try/catch in runTurn | destroy; retry once cold | +| Client disconnects | session-owned runs already survive (`server.ts:237-246`) | on abort: destroy, don't park | +| Approval reply after approval-TTL | pool miss | cold replay + existing decision-map path | +| Multi-replica miss (future) | pool miss | cold replay; later route via `owner:session` affinity | + +### Slices +1. Keep-alive across normal turn boundaries, local only, flag off by default. ~350-500 lines: session-pool.ts, split of sandbox_agent.ts, dispatch wrapper in server.ts, tests through the existing `SandboxAgentDeps` / `createAgentServer(run)` seams. Runner-only; zero wire/SDK/FE change; flag off = byte-identical behavior. +2. Keep-alive across approval pauses. ~200-300 lines: pause.ts park mode, respondPermission resume, client-tool pause seam, interaction resolve ordering. Highest correctness value. +3. Daytona: remove the `isDaytona` gate after slices 1-2 are tested and have run in real use without problems; verify cookie-fetch session reuse and tunnel-mounted cwd survival across a park. Real operational risk is billed idle time and remote liveness. + +## Relation to session resume (option 3, ../harness-session-resume/plan.md) +Independent and complementary. Keep-alive covers the fast conversational loop and the approval window with zero storage work; session resume covers long gaps, runner restarts, and replica moves via the harness's own session files plus `session/load`. Build order: keep-alive slice 1 -> slice 2 -> session-resume slice A; they do not block each other. 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/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md new file mode 100644 index 0000000000..8af728077a --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -0,0 +1,15 @@ +# Session keep-alive: open questions + +None of these block starting slice 1. They refine defaults and edge behavior. Each states a working default so the plan can proceed if Mahmoud does not weigh in. + +1. **Idle TTL default.** 60 seconds is the working default. Longer means more follow-up messages land on the live session; longer also means more idle RAM held per parked session. Is 60 seconds right for the playground's typical pause between messages? + +2. **Approval TTL default.** 10 minutes is the working default. This is how long a parked approval holds its session and permission request open while it waits for a human. Longer helps a human who steps away; longer also holds idle resources. Is 10 minutes right, and should it differ for local versus Daytona later? + +3. **Pool cap default.** About 8 sessions is the working default. This bounds total idle RAM on one replica. What is the realistic concurrent-conversation count per replica we should size for? + +4. **History fingerprint over the pruned array.** The frontend prunes turns with no answer part (`hasAnswer`) before sending, so the server sees fewer messages than the user does. The history fingerprint must be computed over the sent (pruned) array, not the displayed one. Confirm this is the intended contract, since a future change to the pruning would silently invalidate continuations. + +5. **Supersede versus reject on a racing second turn.** The working default is supersede: if a second turn arrives while a session is busy, destroy and cold-start the new turn. The alternative is to reject the second turn. Supersede is simpler and matches "never fail a turn". Confirm supersede is acceptable. + +6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. 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..3d1909616d --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/plan.md @@ -0,0 +1,192 @@ +# Session keep-alive: plan + +- Status: plan drafted for review. Not implemented. Do not commit code from this workspace yet. +- Owner input: Mahmoud. Author: Claude, 2026-07-07. +- Read first: [architecture-notes.md](architecture-notes.md) (code-grounded research, built on here by reference, not repeated). +- Why this exists: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). +- Complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). + +## What this feature is + +Today the runner destroys the sandbox and the harness session at the end of every turn. Keep-alive changes one thing: when a turn ends, the runner keeps the session alive for a short time (a TTL). If the next message arrives inside that window, the runner continues the same live session and sends only the new user text. The harness keeps its full native memory because the process never died. If the window expires, or anything does not match, the runner falls back to today's cold replay. Nothing can get worse than today. + +The feature is flag-gated and local-only first. It changes runner code only. There is no wire change, no SDK change, and no frontend change. With the flag off, behavior is byte-identical to today. + +## Q&A: Mahmoud's questions, answered plainly + +This section answers each question directly. Short sentences, plain language, technical terms where they are exact. + +### Q1. How would it be implemented? (files, the acquireEnvironment/runTurn split, the pool) + +Three pieces of work in the runner. + +1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), and a TTL timer. The pool key is the conversation `session_id`, which already rides the wire from the frontend to the runner. The pool exposes: `get(sessionId)`, `park(sessionId, liveSession, ttl)`, `evict(sessionId)`, `destroy(sessionId)`, and an LRU cap check. Every teardown trigger calls one shared `destroy` path. + +2. A split of `runSandboxAgent` in `services/runner/src/engines/sandbox_agent.ts` into two parts: + - `acquireEnvironment(request)`: everything that is session-scoped and expensive to build. This is roughly today's lines 328 to 699: sign mount credentials, derive the durable cwd, build the run plan, start the sandbox, mount the durable cwd, prepare the workspace, probe capabilities, build the internal tool MCP server, and call `createSession`. The output is an environment object that can serve many turns. The internal tool MCP server must live as long as the session, because its URL is baked into `sessionInit`. + - `runTurn(env, request, emit, signal)`: everything that is per-turn. This is roughly today's lines 712 to 986: start a fresh otel run, create a fresh `PendingApprovalLatch` and `ConversationDecisions`, attach `onEvent` and `onPermissionRequest` listeners (and detach the previous turn's), start the tool relay, send the prompt, resolve usage, and finish and flush the trace. On a continuation the prompt is just the new user text, so `buildTurnText` never runs. + +3. A dispatch wrapper in `services/runner/src/server.ts`. On a session-owned request, when the flag is on, the wrapper checks the pool. On a hit where both fingerprints match and the tail is a fresh user message, it calls `runTurn` on the live environment. On a hit where the new content is an approval decision that matches a parked gate, it answers the parked permission request (slice 2). On a miss, a mismatch, a busy session, or a dead session, it acquires a new environment and runs today's path. A validation failure must never fail the turn. It degrades to cold replay. + +Tests go through the existing seams: `SandboxAgentDeps` for the engine and `createAgentServer(run)` for the HTTP layer. No live harness is needed for the unit tests. + +### Q2. How complex is it really? (honest size per slice, and the genuinely risky part) + +Honest sizes and risk per slice: + +| Slice | Files touched | Rough size | Risk | +|---|---|---|---| +| 1. Keep-alive across normal turns | new `session-pool.ts`; split in `sandbox_agent.ts`; dispatch in `server.ts`; tests | 350 to 500 lines | Medium | +| 2. Keep-alive across approval pauses | `pause.ts`; resume wiring in `sandbox_agent.ts`; client-tool seam; interaction ordering; tests | 200 to 300 lines | High on correctness | +| 3. Daytona | remove the `isDaytona` gate; 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. They are a few dozen lines and carry almost no risk. + +The genuinely risky parts are two, and neither is the pool: + +1. **The teardown-deferral refactor (slice 1).** Today the whole cleanup lives in one `finally` block (`sandbox_agent.ts:1004-1047`). A `finally` runs on every exit, so teardown is guaranteed. Keep-alive removes that guarantee. It moves cleanup out of the `finally` and hands it to a timer and a pool. So every teardown trigger has to be re-proven to fire, and the shared `destroy` path has to be idempotent because the sandbox may already be gone. The exact cleanup steps that must survive are the ones added on the 2026-07-06 child-process-leak incident: graceful `session/cancel`, `mcpAbort.abort()`, `closeToolMcp`, `destroySession`, `destroySandbox`, `dispose`, unmount the durable cwd, and remove the temp dirs. Getting this wrong leaks sandboxes and reparented ACP child processes, which is the same class of bug that incident fixed. + +2. **Listener re-attachment (slice 1, and again in slice 2).** The `sandbox-agent` package supports this cleanly: `onEvent` and `onPermissionRequest` both return an unsubscribe function (verified in the package types, `Session.onEvent`/`onPermissionRequest` return `() => void`). But the current runner code throws that return value away (`sandbox_agent.ts:749` and `acp-interactions.ts:51`). Each turn's listeners close over turn-scoped state (the otel run, the pause controller, the latch, the decisions, the responder). If turn N's listeners are not detached before turn N+1 attaches its own, both fire, and turn N+1's events reach turn N's dead emitter and the wrong decision map. So the refactor must capture those unsubscribe functions and call them at the start of each turn. The package makes this possible; the wiring is net-new work and is fiddly because of how many turn-scoped closures the listeners capture. + +Slice 2's risk is correctness density, not size. It touches the pause path where several existing rules interact: the F-024 clobber rule, the pause-time sweep of orphaned tool calls, and the "a pause sends no harness reply" contract. Changing pause from "destroy the session" to "park and hold the permission request" has to preserve all three while adding a new `resume()` step. + +### Q3. How do we keep it alive? (what "alive" means, what it costs while idle) + +"Alive" means these processes and objects stay running between turns: + +- the `sandbox-agent` daemon process, +- the ACP adapter subprocess it spawned (`claude-agent-acp` or `pi-acp`), +- the harness process the adapter drives, +- the ACP session object bound in the daemon's session registry, +- the internal tool MCP server (its URL is baked into `sessionInit`, so it must outlive the session), +- the relay directory, the mounted durable cwd, and the geesefs mount. + +While idle, nothing is executing. No prompt is running, so there is no CPU cost beyond the idle footprint. The cost is memory: + +- **Local: host RAM only.** A few hundred megabytes per parked session (the daemon plus the adapter plus the harness). No money is spent. The pool cap (about eight sessions) bounds the total. +- **Daytona: billed wall-clock time.** An idle remote sandbox costs money for as long as it is alive. This is why slice 3 is gated behind local success. The existing 15-minute auto-stop and ephemeral auto-delete backstops (`provider.ts`) still reap leaks, but they are a safety net, not the primary control. + +### Q4. How do we auto-kill it? (TTL, LRU, all teardown triggers, SIGTERM, and the expired approval park) + +Every teardown trigger calls the same idempotent `destroy(sessionId)` path, which runs the full cleanup listed in Q2. + +Triggers: + +- **Idle TTL expiry.** An idle session gets a timer (default 60 seconds). When it fires, destroy. +- **Approval TTL expiry.** A session parked on an approval gets a longer timer (default 10 minutes). When it fires, destroy the parked session and abandon the held permission request. This degrades, it does not fail: the frontend still holds the approval prompt, and when the human clicks, the frontend resends the conversation with the approval envelope, the request misses the pool, and today's cold decision-map path answers it. So an expired approval park falls back to exactly today's behavior. +- **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.** On the next request, if either fingerprint does not match, evict and destroy the parked session and run the cold path. +- **Explicit stop.** `POST /kill` drains the whole pool. +- **Runner shutdown (SIGTERM or SIGINT).** Keep parked sandboxes registered in `inFlightSandboxes` so the existing shutdown handler (`server.ts:444`, `destroyInFlightSandboxes`) reaps them before the process exits. On a hard `SIGKILL` or OOM the process dies with its local child processes; the Daytona auto-stop backstop covers the remote case the signal can never reach. +- **Client disconnect.** On abort, destroy, do not park. A session-owned run already survives disconnect during a turn (`server.ts:237-246`), but a disconnect means the turn is 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 (detected by a liveness probe on the next acquire), or a continuation that throws mid-turn all evict and fall back to cold replay. A mid-turn continuation failure retries once cold. + +### Q5. If we have keep-alive, how do sessions connect to it? (the pool key, and the relation to option 3) + +The pool key is the conversation `session_id`. The frontend mints it once per chat tab and sends it on every request. It survives the whole journey without a wire change: frontend `agentRequest.ts` sends it, the SDK `handler.py` forwards it, and the runner receives it as `request.sessionId` (`protocol.ts:385`, resolved by `resolveRunSessionId`). So a follow-up message in the same conversation carries the same key, and the runner finds the parked session under that key. + +Keep-alive and session resume (option 3) are different memories and they compose. Neither replaces the other: + +- **Keep-alive is memory within the TTL window, on the same replica.** The live process still holds the full native context (tool calls, results, and thinking). It is the fast path. It costs idle resources while it holds the session. +- **Session resume is memory across restarts, long gaps, and replica moves.** It reloads the harness's own session files with `session/load` after the process is gone. It costs storage and a load per turn, but zero idle resources. + +The two form a three-tier fallback keyed on the same `session_id`: + +1. Pool hit inside the TTL: continue the live session (keep-alive). Highest fidelity, fastest. +2. Pool miss but a recorded harness session id and the files exist: reload with `session/load` (session resume). Full fidelity, medium cost. +3. Both miss: cold replay of the flattened transcript (today's path). Always available, always correct. + +### Q6. Before or after option 3 (session resume)? (recommendation with reasons) + +Build keep-alive before session resume. Order: keep-alive slice 1, then keep-alive slice 2, then session-resume slice A. + +Reasons: + +- Keep-alive removes the two production approval failures immediately. Both failures in the report (argument drift and task restart) come from destroying the session on approval. Slice 2 holds the session and its permission request open, so the tool runs with its original byte-exact arguments and the model never re-issues the call. That is the acute pain, and keep-alive kills it. +- Keep-alive is runner-only. It changes no wire field, no SDK, no frontend, and no storage. That is the smallest blast radius of the two features. +- Keep-alive has no upstream blocker. Session resume needs a `session/load` call that `sandbox-agent` 0.4.2 does not forward through its managed session API, so it needs a `pnpm patch` or the raw ACP passthrough first. Keep-alive needs none of that. +- Keep-alive establishes the seams session resume reuses: the config and history fingerprints, and the "skip `buildTurnText` on a continuation" branch. Session resume slice A reuses both. + +Session resume is still the target architecture, because it is the only path that restores full fidelity across restarts, replica moves, 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 approval request reaches the same UI. The human approves or denies the same way. What changes is what the runner does after the click. + +Today, after the click: the runner destroyed the session when it paused, so it cold-starts a fresh session, replays a flattened transcript, and the approval waits in a decision map keyed on the tool name plus the canonical JSON of the exact arguments. A fresh model has to re-issue the tool call. If its regenerated arguments match the stored key, the call runs. If not, the gate parks again and the human sees a new prompt. This is where both production turns failed. + +With keep-alive on the live path (inside the approval TTL): the parked `LiveSession` still holds the still-pending permission request and the suspended `prompt()` promise. The runner calls `session.respondPermission(parkedId, "once" | "reject")`. The original prompt continues. The tool executes with its original byte-exact arguments. The new turn's emitter streams the remaining events. No model re-issues anything, so argument drift and task restart cannot happen. + +Both paths stay. The live path runs inside the approval TTL. The cold path runs when the approval TTL expired, the runner restarted, or the pool missed, and it uses today's decision-map machinery unchanged. So keep-alive makes the common case reliable and leaves the fallback exactly as correct as it is now. + +Which approval phases change and which do not: + +| Phase | Today | With keep-alive | Changed? | +|---|---|---|---| +| Model calls a tool, gate raised | `onPermissionRequest` / relay gate fires | same | No | +| Runner emits `interaction_request`, records the interaction, sends the approval part to the UI | as today | as today | No | +| Turn ends with `stopReason: "paused"`, egress emits `finish` | the pause race ends the turn | the pause race still ends the turn (the session parks instead of being destroyed) | No (turn still ends) | +| Human clicks approve or deny in the UI | as today | as today | No | +| Frontend resends the conversation with the approval envelope | as today | as today (no frontend change) | No | +| Runner acts on the decision | destroy, cold-start, replay, model re-issues the call, match on the decision map | live path: answer the parked permission request, original call runs with exact args; cold path: today's decision map | Yes (this is the whole win) | +| Tool executes, output streams, UI part flips to output-available | reached only after a possible re-park loop | reached in one round on the live path; cold path unchanged | Reliability improves; end state is the same | + +The frontend approval part lifecycle (input-available, approval-requested, approval-responded, output-available) is unchanged. On the live path the transition from approval-responded to output-available becomes reliable and single-round. On the cold path it stays exactly as today, including the possible re-park. The durable interaction rows are unchanged too: create on pause (as today), resolve on the decision (the `onResolveInteraction` hook at `sandbox_agent.ts:850`). One new ordering rule is needed: on the live resume path, a new turn's `cancelStaleInteractions` (`server.ts:275`) must not cancel the interaction the runner is about to resolve on the same session. + +### Q8. Daytona (slice 3) + +Implement Daytona only after slices 1 and 2 are tested and have run in real use without problems. Enable local-only first. + +The reason is cost and remote failure modes. A parked local session costs host RAM. A parked Daytona sandbox costs billed wall-clock time for as long as it is alive, and remote liveness adds failure classes the local path does not have (tunnel drops, cookie-fetch session reuse, mounted cwd survival across a park). The existing 15-minute auto-stop and ephemeral auto-delete backstops still reap leaks, but they are a safety net, not a reason to enable Daytona keep-alive early. Turn Daytona on only after the local path has run in real use with no problems. + +## Could a minimal version work in about an hour? + +Two different "minimal" versions, and the honest answers differ. + +**A one-hour spike: yes, and it is worth doing.** The smallest thing that proves the idea is spike E5 from the failure report: in a test, drive one `sandbox-agent` `Session` with two sequential `prompt()` calls. 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 validates the core mechanic and de-risks slice 1. Spike E6 does the same for slice 2: raise a gate, hold the permission request for a minute, then `respondPermission(id, "once")`, and show the original prompt continues with the original arguments. Both spikes 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 the teardown-deferral refactor and the listener re-attachment (see Q2). You cannot get continue-on-match working without deferring teardown, and deferring teardown is exactly the risky part. A one-hour hack would either skip the idempotent shared `destroy` path (which reintroduces the 2026-07-06 process-leak class) or skip detaching the previous turn's listeners (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 version. + +Recommendation: spend the first hour on spike E5 (and E6 if time allows). Then build slice 1 properly. The spike is a stepping stone. The hack is not. + +## Slices (summary; full detail in architecture-notes.md) + +1. **Keep-alive across normal turns.** Local only, flag off by default, runner-only. Pool, the acquireEnvironment/runTurn split, the dispatch wrapper, and the shared idempotent destroy path. Flag off means byte-identical behavior. Size 350 to 500 lines. Risk medium (the teardown-deferral refactor and listener re-attachment). +2. **Keep-alive across approval pauses.** Park mode in `pause.ts`, the `respondPermission` resume, the client-tool pause seam, and the interaction resolve ordering. Size 200 to 300 lines. Highest correctness value; highest correctness risk. +3. **Daytona.** Remove the `isDaytona` gate after slices 1 and 2 have run in real use with no problems. Verify cookie-fetch session reuse and mounted cwd survival across a park. Small code, real operational risk (billed idle time, remote liveness). + +## Flags and defaults + +- `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off). +- `AGENTA_RUNNER_SESSION_TTL_MS` (default 60000). +- `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (default 600000). +- `AGENTA_RUNNER_SESSION_POOL_MAX` (default about 8). + +Add these to the runner's config surface, not read ad hoc from `process.env` scattered across files. Requests without a `session_id` never park. + +## Failure modes (detect, degrade, never fail the turn) + +Carried from architecture-notes.md; unchanged here: + +| 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 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 | +| Multi-replica miss (future) | pool miss | cold replay; later route via `owner:session` affinity | + +## Verification plan + +Before slice 1: run spike E5 (two prompts, one session) to confirm native memory and clean listener re-attach. +Before slice 2: run spike E6 (hold a permission request, then respond) to confirm the parked prompt continues with 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. + +## 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). +- 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..663dfd8ed0 --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -0,0 +1,40 @@ +# Session keep-alive: status + +Source of truth for progress. Keep this current. + +## Current state (2026-07-07) + +- Phase: planning. Plan drafted and awaiting Mahmoud's review. +- Code: none written. Do not commit code from this workspace yet (another lane is in flight). +- Research: [architecture-notes.md](architecture-notes.md) verified against the current runner code. See "Drift check" below. + +## 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 the conversation `session_id`, which already rides the wire. + +## 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. + +## Next steps + +1. Mahmoud reviews the plan and the open questions. +2. Run spike E5 (two prompts, one session) before slice 1. +3. Run spike E6 (hold a permission request, then respond) before slice 2. +4. Implement slice 1 behind the flag. Verify flag-off is byte-identical. From 36c56bc03591bd2f9180e31017175934e5a9a7fd Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 10:30:49 +0200 Subject: [PATCH 02/13] docs(design): fold the Codex xhigh review findings into the session-keepalive plan Slice 2 scoped to Claude ACP permission gates only (per-gate-type table); session-lifetime listeners demuxing into the current-turn sink; pool-owned complete idempotent destroy() drained on shutdown; project-scoped pool key plus credential epoch; resumed approvals execute with the original turn's baked environment; incremental acquire finalizers; pruned-history fingerprint contract pinned with tests. Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../session-keepalive/architecture-notes.md | 20 +++++--- .../session-keepalive/open-questions.md | 6 ++- .../projects/session-keepalive/plan.md | 46 +++++++++++++++---- .../projects/session-keepalive/status.md | 20 ++++---- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md index e873c4f6d2..015e211961 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -17,13 +17,14 @@ Today the runner destroys the sandbox and harness session at the end of every tu ## Design ### Pool and keying -- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `request.sessionId`. States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. +- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `:` (amended after review: the caller-supplied `sessionId` alone must not be the key; the project scope comes from the request's authenticated context, the same values the mount-signing call uses, so two projects can never share a live harness process). States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. +- Credential epoch (amended after review): the park records the mount-credential expiry and a hash of the resolved credential identities. An expired or rotated epoch is treated as a fingerprint mismatch: evict and cold-start with fresh credentials. Parked sessions must not outlive their baked credentials. - Requests without a `sessionId` never park (non-playground callers keep today's semantics). ### Continuation versus cold decision Each parked session records two fingerprints: 1. `configFingerprint`: canonical-JSON hash over config-bearing request fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, systemPrompt, appendSystemPrompt, tools, skills, customTools, mcpServers, toolCallback.endpoint, permissions, sandboxPermission, harnessFiles, workflow revision id/version, is_draft). Exclude per-turn volatiles (messages, turnId, trace propagation, telemetry headers which rotate ~15 min, secret values). -2. `historyFingerprint`: ordered prior user-message texts plus ordered tool-call ids (both survive the egress/ingress round trip byte-stable), plus `promptCount`. +2. `historyFingerprint`: ordered prior user-message texts plus ordered tool-call ids (both survive the egress/ingress round trip byte-stable), plus `promptCount`. Pinned contract (amended after review): computed over the message array the server receives, which is the frontend's PRUNED array (the FE drops answer-less assistant turns before sending). Unit tests pin the pruned-array contract so a future FE pruning change trips a test instead of silently breaking continuations; a fingerprint miss degrades to cold replay, never a wrong continuation. On an incoming request with a pool hit: - Continue (normal): fingerprints match, tail is a fresh user message -> `session.prompt(newUserText)`. No `buildTurnText`. @@ -32,8 +33,10 @@ On an incoming request with a pool hit: ### Turn-flow refactor Split `runSandboxAgent` into: -- `acquireEnvironment(request)`: mount signing through `createSession` + MCP wiring (today lines 328-699). Session-scoped: sandbox handle, session handle, internal tool-MCP server (its URL is baked into sessionInit, so it must live as long as the session), relay dir, mounted cwd, workspace, skills dirs. -- `runTurn(env, request, emit, signal)`: per-turn otel run, fresh `PendingApprovalLatch`, fresh `ConversationDecisions`, re-attach `onEvent`/`onPermissionRequest` (detach the previous turn's), restart toolRelay, prompt, resolve usage, flush otel. +- `acquireEnvironment(request)`: mount signing through `createSession` + MCP wiring (today lines 328-699). Session-scoped: sandbox handle, session handle, internal tool-MCP server (its URL is baked into sessionInit, so it must live as long as the session), relay dir, mounted cwd, workspace, skills dirs. Finalizers register incrementally as each resource is acquired; a mid-acquire failure runs the finalizers registered so far (reverse order) through the same `destroy()` the pool uses. A half-built environment cannot leak. +- `runTurn(env, request, emit, signal)`: per-turn otel run, fresh `PendingApprovalLatch`, fresh `ConversationDecisions`, swap the environment's current-turn sink to this turn's handlers, restart toolRelay, prompt, resolve usage, flush otel. + +Listener model (amended after review): `onEvent`/`onPermissionRequest` attach ONCE in `acquireEnvironment` for the session's lifetime and demux into a mutable `currentTurn` sink that `runTurn` swaps in and out. Per-turn detach/attach is wrong for this package: the listener registries are plain Sets, an event with no listener is silently dropped, and a permission request with no listener is CANCELLED, so any detach window is a drop/cancel window. Events arriving with no active turn hit an explicit between-turns handler (permission requests park in slice 2 or cancel by policy; stray events are logged and dropped deliberately). ### The approval interaction (the big win) F-040 (docs/design/agent-workflows/projects/qa/f040-park-terminal-plan.md) destroyed the session on pause for three reasons: (a) the HTTP turn must end (Claude never resolves `prompt()` on an unanswered gate), (b) the sandbox would leak if the turn never ended, (c) the package blocks manual `session/cancel`, so destroy was the only clean cancel. A TTL park invalidates (b) and (c) and preserves (a): the turn still ends with `stopReason: "paused"` (the existing race at `sandbox_agent.ts:899-912` still works), but the `LiveSession` parks holding the still-pending `prompt()` promise and the unanswered permission id. @@ -44,10 +47,15 @@ Required changes in park mode: the pause controller must not fire the destroy ca Note: `acp-fetch.ts` already disables undici header/body timeouts specifically so a paused HITL turn's held ACP connection survives human-timescale delays. The holding infrastructure exists. +Scope (amended after review): the park-and-answer path applies to Claude ACP permission gates ONLY in v1. The other three gate mechanisms stay cold, each for a structural reason: Pi custom-tool gates block on the file relay inside the sandbox with their own deadline (`RELAY_TIMEOUT_MS` 60 s default; the dispatch shim polls a response file), Pi builtin gates block synchronously inside `extensions/agenta.ts` on `relayPermissionCheck`, and a client-tool MCP pause deterministically ABORTS the in-flight HTTP request (`tool-mcp-http.ts`), so by turn end there is nothing held to answer. The dispatch takes the park-and-answer path only for a parked Claude ACP permission id; any other pending-gate shape evicts to cold. Tests assert the non-Claude gates stay cold. The full consequence table is in plan.md Q7. + +Execution context on resume (amended after review): the ORIGINAL turn's baked environment executes the tool (its signed mount credentials, resolved secrets, callback auth, harness env, sandbox); the NEW turn's context owns streaming and tracing (otel run, emitter, interaction resolve). The credential epoch bounds staleness: expired or rotated credentials evict instead of resuming. + ### Lifecycle and safety - Flags: `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off), `AGENTA_RUNNER_SESSION_TTL_MS` (60000), `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (600000), `AGENTA_RUNNER_SESSION_POOL_MAX` (~8). - Eviction: LRU on idle entries when full; never evict busy. Parking is best-effort; pool full -> tear down as today. -- Teardown triggers: TTL expiry, fingerprint mismatch, explicit stop (`POST /kill` drains the pool), runner shutdown (keep parked sandboxes in `inFlightSandboxes` so the SIGTERM handler reaps them), rejected parked prompt promise. +- Teardown triggers: TTL expiry, fingerprint mismatch, credential-epoch expiry, explicit stop (`POST /kill` drains the pool), runner shutdown, rejected parked prompt promise. +- Runner shutdown (corrected after review): `inFlightSandboxes` is not sufficient; `destroyInFlightSandboxes` only calls `destroySandbox` and skips the relay stop, `mcpAbort`, `closeToolMcp`, `destroySession`, dispose, unmount, and temp-dir removal. Each `LiveSession` carries the complete idempotent `destroy()` (all the finalizers the old `finally` ran), and the SIGTERM/SIGINT path drains the pool through `pool.destroyAll()` (timeout-bounded). `inFlightSandboxes` stays as a second line of defense for the sandbox handle only. - Idle cost: local = host RAM only (a few hundred MB per session: daemon + adapter + harness processes; the 2026-07-06 child-process-leak notes in the finally block list exactly what to track). Daytona = billed idle time; existing backstops (15-min auto-stop, ephemeral auto-delete, `provider.ts:41-98`) still reap leaks. Enable local-only first. ### Failure modes (detect -> degrade, never fail the turn) @@ -63,7 +71,7 @@ Note: `acp-fetch.ts` already disables undici header/body timeouts specifically s ### Slices 1. Keep-alive across normal turn boundaries, local only, flag off by default. ~350-500 lines: session-pool.ts, split of sandbox_agent.ts, dispatch wrapper in server.ts, tests through the existing `SandboxAgentDeps` / `createAgentServer(run)` seams. Runner-only; zero wire/SDK/FE change; flag off = byte-identical behavior. -2. Keep-alive across approval pauses. ~200-300 lines: pause.ts park mode, respondPermission resume, client-tool pause seam, interaction resolve ordering. Highest correctness value. +2. Keep-alive across approval pauses, Claude ACP permission gates only. ~200-300 lines: pause.ts park mode, respondPermission resume, interaction resolve ordering. Pi relay gates, Pi builtin gates, and client-tool MCP pauses stay cold (asserted by tests). Highest correctness value. 3. Daytona: remove the `isDaytona` gate after slices 1-2 are tested and have run in real use without problems; verify cookie-fetch session reuse and tunnel-mounted cwd survival across a park. Real operational risk is billed idle time and remote liveness. ## Relation to session resume (option 3, ../harness-session-resume/plan.md) diff --git a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md index 8af728077a..3f62eefc99 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -8,8 +8,10 @@ None of these block starting slice 1. They refine defaults and edge behavior. Ea 3. **Pool cap default.** About 8 sessions is the working default. This bounds total idle RAM on one replica. What is the realistic concurrent-conversation count per replica we should size for? -4. **History fingerprint over the pruned array.** The frontend prunes turns with no answer part (`hasAnswer`) before sending, so the server sees fewer messages than the user does. The history fingerprint must be computed over the sent (pruned) array, not the displayed one. Confirm this is the intended contract, since a future change to the pruning would silently invalidate continuations. +4. **History fingerprint over the pruned array.** DECIDED (2026-07-08 review fold): the fingerprint is computed over the sent (pruned) array, and unit tests pin the contract 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.** The working default is supersede: if a second turn arrives while a session is busy, destroy and cold-start the new turn. The alternative is to reject the second turn. Supersede is simpler and matches "never fail a turn". Confirm supersede is acceptable. -6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. +6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. Note (2026-07-08 review fold): credential identity is handled separately by the credential epoch on the park record, not by the config fingerprint; secret VALUES stay out of any hash. + +Decisions folded from the 2026-07-08 Codex review (recorded, not open): slice 2 v1 parks Claude ACP permission gates only (plan.md Q7 scope table); listeners are session-lifetime with a current-turn demux; the pool key is project-scoped and parks carry a credential epoch; a resumed approval executes with the original turn's baked environment while the new turn owns streaming and tracing; a partial acquireEnvironment failure cleans up through the incrementally registered finalizers. diff --git a/docs/design/agent-workflows/projects/session-keepalive/plan.md b/docs/design/agent-workflows/projects/session-keepalive/plan.md index 3d1909616d..e4c3510d5b 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/plan.md +++ b/docs/design/agent-workflows/projects/session-keepalive/plan.md @@ -1,7 +1,8 @@ # Session keep-alive: plan -- Status: plan drafted for review. Not implemented. Do not commit code from this workspace yet. -- Owner input: Mahmoud. Author: Claude, 2026-07-07. +- Status: plan approved by Mahmoud subject to the Codex xhigh review findings, which are folded in below. Implementation in progress (2026-07-08). +- Owner input: Mahmoud. Author: Claude, 2026-07-07. Amended 2026-07-08 after the Codex xhigh plan review. +- Review changelog (2026-07-08): (1) slice 2 is scoped to Claude ACP permission gates only; a per-gate-type table below records what every other gate type does on a park. (2) The per-turn listener detach/attach design is replaced with session-lifetime listeners that demux into the active turn's sink. (3) The SIGTERM claim is corrected: the pool owns a complete idempotent destroy per session; `inFlightSandboxes` alone only destroys the sandbox. (4) The pool key is scoped by project, and a credential-epoch rule evicts parked sessions when credentials rotate. (5) A resumed approval executes with the original turn's baked environment; the new turn governs streaming and tracing. (6) A partial acquireEnvironment failure cleans up through the same finalizer list. (7) The history fingerprint is pinned to the pruned (sent) message array, with tests. - Read first: [architecture-notes.md](architecture-notes.md) (code-grounded research, built on here by reference, not repeated). - Why this exists: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). - Complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). @@ -20,11 +21,15 @@ This section answers each question directly. Short sentences, plain language, te Three pieces of work in the runner. -1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), and a TTL timer. The pool key is the conversation `session_id`, which already rides the wire from the frontend to the runner. The pool exposes: `get(sessionId)`, `park(sessionId, liveSession, ttl)`, `evict(sessionId)`, `destroy(sessionId)`, and an LRU cap check. Every teardown trigger calls one shared `destroy` path. +1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. The pool key is NOT the caller-supplied `session_id` alone: it is the project scope plus the conversation id (`:`, with the project id taken from the request's own authenticated scope, the same values the mount-signing call uses). Two callers from different projects who send the same `session_id` must never share a live harness process. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a credential epoch (below), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), a TTL timer, and a complete `destroy()` closure (Q4). The pool exposes: `get(key)`, `park(key, liveSession, ttl)`, `evict(key)`, `destroy(key)`, `destroyAll()`, and an LRU cap check. Every teardown trigger calls the session's one idempotent `destroy` path. + + Credential epoch: a parked session baked its credentials (signed mount credentials, resolved secrets, tool-callback auth) at acquire time. A parked session must not outlive them. The park records a credential epoch (the mount-credential expiry timestamp, and a hash over the resolved secret and callback-auth identities). On the next request, if the epoch expired or the incoming request resolves to different credential identities, treat it as a fingerprint mismatch: evict, destroy, cold-start with fresh credentials. The idle TTL (60 s) is far shorter than the mount-credential lifetime, so this mostly matters for the longer approval TTL. 2. A split of `runSandboxAgent` in `services/runner/src/engines/sandbox_agent.ts` into two parts: - - `acquireEnvironment(request)`: everything that is session-scoped and expensive to build. This is roughly today's lines 328 to 699: sign mount credentials, derive the durable cwd, build the run plan, start the sandbox, mount the durable cwd, prepare the workspace, probe capabilities, build the internal tool MCP server, and call `createSession`. The output is an environment object that can serve many turns. The internal tool MCP server must live as long as the session, because its URL is baked into `sessionInit`. - - `runTurn(env, request, emit, signal)`: everything that is per-turn. This is roughly today's lines 712 to 986: start a fresh otel run, create a fresh `PendingApprovalLatch` and `ConversationDecisions`, attach `onEvent` and `onPermissionRequest` listeners (and detach the previous turn's), start the tool relay, send the prompt, resolve usage, and finish and flush the trace. On a continuation the prompt is just the new user text, so `buildTurnText` never runs. + - `acquireEnvironment(request)`: everything that is session-scoped and expensive to build. This is roughly today's lines 328 to 699: sign mount credentials, derive the durable cwd, build the run plan, start the sandbox, mount the durable cwd, prepare the workspace, probe capabilities, build the internal tool MCP server, and call `createSession`. The output is an environment object that can serve many turns. The internal tool MCP server must live as long as the session, because its URL is baked into `sessionInit`. Acquire builds its finalizer list incrementally: each acquired resource registers its finalizer the moment it exists (sandbox started, cwd mounted, MCP server up, session created, temp dirs made). If a later acquire step throws, the environment's `destroy()` runs the finalizers registered so far, in reverse order, and the failure surfaces as it does today. This makes a half-built environment impossible to leak, and it is the same `destroy()` the pool uses later. + - `runTurn(env, request, emit, signal)`: everything that is per-turn. This is roughly today's lines 712 to 986: start a fresh otel run, create a fresh `PendingApprovalLatch` and `ConversationDecisions`, point the environment's turn sink at this turn's handlers (see the listener model below), start the tool relay, send the prompt, resolve usage, and finish and flush the trace. On a continuation the prompt is just the new user text, so `buildTurnText` never runs. + + Listener model (amended after review): the environment attaches `onEvent` and `onPermissionRequest` exactly once, in `acquireEnvironment`, for the life of the session. Those session-lifetime listeners demux into a mutable current-turn sink: a `env.currentTurn` reference that `runTurn` swaps in at turn start and clears at turn end. The earlier idea (detach turn N's listeners, attach turn N+1's) is wrong for this package: the `sandbox-agent` listener registries are plain Sets, an event that fires with no listener is silently dropped, and a permission request that arrives with no listener is CANCELLED. Any detach/attach window between turns is therefore a drop/cancel window. With session-lifetime listeners there is no window. Events that arrive while no turn is active (between turns, or after a park) go to a small between-turns handler on the environment: permission requests park (slice 2) or cancel-by-policy, stray events are logged and dropped by decision rather than by accident. 3. A dispatch wrapper in `services/runner/src/server.ts`. On a session-owned request, when the flag is on, the wrapper checks the pool. On a hit where both fingerprints match and the tail is a fresh user message, it calls `runTurn` on the live environment. On a hit where the new content is an approval decision that matches a parked gate, it answers the parked permission request (slice 2). On a miss, a mismatch, a busy session, or a dead session, it acquires a new environment and runs today's path. A validation failure must never fail the turn. It degrades to cold replay. @@ -46,7 +51,7 @@ The genuinely risky parts are two, and neither is the pool: 1. **The teardown-deferral refactor (slice 1).** Today the whole cleanup lives in one `finally` block (`sandbox_agent.ts:1004-1047`). A `finally` runs on every exit, so teardown is guaranteed. Keep-alive removes that guarantee. It moves cleanup out of the `finally` and hands it to a timer and a pool. So every teardown trigger has to be re-proven to fire, and the shared `destroy` path has to be idempotent because the sandbox may already be gone. The exact cleanup steps that must survive are the ones added on the 2026-07-06 child-process-leak incident: graceful `session/cancel`, `mcpAbort.abort()`, `closeToolMcp`, `destroySession`, `destroySandbox`, `dispose`, unmount the durable cwd, and remove the temp dirs. Getting this wrong leaks sandboxes and reparented ACP child processes, which is the same class of bug that incident fixed. -2. **Listener re-attachment (slice 1, and again in slice 2).** The `sandbox-agent` package supports this cleanly: `onEvent` and `onPermissionRequest` both return an unsubscribe function (verified in the package types, `Session.onEvent`/`onPermissionRequest` return `() => void`). But the current runner code throws that return value away (`sandbox_agent.ts:749` and `acp-interactions.ts:51`). Each turn's listeners close over turn-scoped state (the otel run, the pause controller, the latch, the decisions, the responder). If turn N's listeners are not detached before turn N+1 attaches its own, both fire, and turn N+1's events reach turn N's dead emitter and the wrong decision map. So the refactor must capture those unsubscribe functions and call them at the start of each turn. The package makes this possible; the wiring is net-new work and is fiddly because of how many turn-scoped closures the listeners capture. +2. **The turn demux (slice 1, and again in slice 2).** Each turn's handlers close over turn-scoped state (the otel run, the pause controller, the latch, the decisions, the responder). 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 dropped, and a permission request with no listener is CANCELLED. So the design uses session-lifetime listeners attached once in `acquireEnvironment`, demuxing into a mutable `currentTurn` sink that `runTurn` swaps in and out (see Q1). The risk moves from "did we detach in time" to "does every event route to the right turn's closures", which is testable with a fake session: two sequential turns must each see only their own events, and an event fired between turns must hit the between-turns handler, never a dead turn's closures. Slice 2's risk is correctness density, not size. It touches the pause path where several existing rules interact: the F-024 clobber rule, the pause-time sweep of orphaned tool calls, and the "a pause sends no harness reply" contract. Changing pause from "destroy the session" to "park and hold the permission request" has to preserve all three while adding a new `resume()` step. @@ -77,7 +82,7 @@ Triggers: - **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.** On the next request, if either fingerprint does not match, evict and destroy the parked session and run the cold path. - **Explicit stop.** `POST /kill` drains the whole pool. -- **Runner shutdown (SIGTERM or SIGINT).** Keep parked sandboxes registered in `inFlightSandboxes` so the existing shutdown handler (`server.ts:444`, `destroyInFlightSandboxes`) reaps them before the process exits. On a hard `SIGKILL` or OOM the process dies with its local child processes; the Daytona auto-stop backstop covers the remote case the signal can never reach. +- **Runner shutdown (SIGTERM or SIGINT).** Corrected after review: the `inFlightSandboxes` registry is NOT enough, because `destroyInFlightSandboxes` only calls `destroySandbox`. It does not stop the relay, abort `mcpAbort`, close the internal tool MCP server, call `destroySession`, dispose the daemon handle, unmount the durable cwd, or remove the temp dirs. Instead, each `LiveSession` carries the complete idempotent `destroy()` built up during `acquireEnvironment` (every finalizer the old `finally` block ran), and the shutdown handler drains the pool through `pool.destroyAll()`, timeout-bounded the same way `destroyInFlightSandboxes` is. Parked sessions stay registered in `inFlightSandboxes` too, as a second line of defense for the sandbox itself, but the pool's `destroy()` is the authoritative cleanup. On a hard `SIGKILL` or OOM the process dies with its local child processes; the Daytona auto-stop backstop covers the remote case the signal can never reach. - **Client disconnect.** On abort, destroy, do not park. A session-owned run already survives disconnect during a turn (`server.ts:237-246`), but a disconnect means the turn is 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 (detected by a liveness probe on the next acquire), or a continuation that throws mid-turn all evict and fall back to cold replay. A mid-turn continuation failure retries once cold. @@ -133,6 +138,25 @@ Which approval phases change and which do not: The frontend approval part lifecycle (input-available, approval-requested, approval-responded, output-available) is unchanged. On the live path the transition from approval-responded to output-available becomes reliable and single-round. On the cold path it stays exactly as today, including the possible re-park. The durable interaction rows are unchanged too: create on pause (as today), resolve on the decision (the `onResolveInteraction` hook at `sandbox_agent.ts:850`). One new ordering rule is needed: on the live resume path, a new turn's `cancelStaleInteractions` (`server.ts:275`) must not cancel the interaction the runner is about to resolve on the same session. +### Slice 2 scope: which gate types park (amended after review) + +The runner has four distinct approval-gate mechanisms, and only one of them holds a promise the runner can answer later. Slice 2 v1 parks that one and leaves the other three on the cold path, explicitly: + +| Gate type | How it pauses today | Slice 2 v1 | What staying cold means | +|---|---|---|---| +| Claude ACP permission gate | `onPermissionRequest` fires; the pending request parks in the daemon (`pendingPermissionRequests`); `respondPermission(id, reply)` is callable at any later time | **Parks.** The `LiveSession` holds the pending permission id and the suspended `prompt()` promise; on a validated resume the runner calls `respondPermission` | n/a (this is the live path) | +| Pi custom-tool gate (file relay) | the tool call blocks inside the sandbox on the relay response file, with a hard deadline (`RELAY_TIMEOUT_MS`, default 60 s, `relay.ts`; deadline loop in the dispatch shim) | **Stays cold.** The session is not parked for resume-by-answer; the pause tears down or times out as today | Identical to today: the relay deadline expires, the turn ends paused, the approval resumes through the cold decision map. A parked live session cannot answer it because the blocked side is a file-poll inside the sandbox with its own 60 s clock, not a runner-held promise | +| Pi builtin gate (extension) | the `pi.on("tool_call")` hook in `extensions/agenta.ts` blocks synchronously on `relayPermissionCheck` until the relay answers or times out | **Stays cold.** Same as the custom-tool relay: the block lives inside the Pi process | Identical to today. The synchronous block cannot survive a turn boundary, so there is nothing to park | +| Client-tool MCP pause | a paused client tool emits no JSON-RPC result and deterministically ABORTS the in-flight HTTP request to the internal MCP server (`tool-mcp-http.ts`) | **Stays cold.** The abort has already destroyed the in-flight call by the time the turn ends; there is no held request to answer | Identical to today: the client tool resumes through the cold path. Parking these needs a redesign of the MCP pause (hold the HTTP response open instead of aborting), which is out of scope for v1 | + +Consequence: in v1, the live approval win applies to Claude harness runs gated through ACP permissions. Pi runs and client-tool pauses keep exactly today's behavior, no better and no worse. The dispatch must therefore only take the park-and-answer path when the parked gate is a Claude ACP permission id; any other pending-gate shape on a parked session is a mismatch that evicts to cold. A test asserts this. + +### Whose context does a resumed approval execute with? (amended after review) + +Decision: the ORIGINAL turn's baked environment executes the tool; the NEW turn's context governs streaming and tracing. + +Concretely: when the runner answers a parked permission request, the tool call that then executes inside the live session runs with everything the original `acquireEnvironment` baked in: the original signed mount credentials, resolved secrets, tool-callback endpoint and auth, harness env, and sandbox. The resume request does not re-bake any of that into the live session (it cannot; the harness process already holds it). What the new turn owns is the egress side: the new turn's otel run traces the resumed events, the new turn's emitter streams them, and the new turn's interaction hooks resolve the durable interaction row. The credential-epoch rule above bounds the staleness: if the original credentials expired or rotated before the resume arrived, the pool evicts instead of resuming, and the cold path re-bakes everything fresh. + ### Q8. Daytona (slice 3) Implement Daytona only after slices 1 and 2 are tested and have run in real use without problems. Enable local-only first. @@ -151,8 +175,8 @@ Recommendation: spend the first hour on spike E5 (and E6 if time allows). Then b ## Slices (summary; full detail in architecture-notes.md) -1. **Keep-alive across normal turns.** Local only, flag off by default, runner-only. Pool, the acquireEnvironment/runTurn split, the dispatch wrapper, and the shared idempotent destroy path. Flag off means byte-identical behavior. Size 350 to 500 lines. Risk medium (the teardown-deferral refactor and listener re-attachment). -2. **Keep-alive across approval pauses.** Park mode in `pause.ts`, the `respondPermission` resume, the client-tool pause seam, and the interaction resolve ordering. Size 200 to 300 lines. Highest correctness value; highest correctness risk. +1. **Keep-alive across normal turns.** Local only, flag off by default, runner-only. Pool (project-scoped key, credential epoch), the acquireEnvironment/runTurn split with incremental finalizers, session-lifetime listeners demuxing into the current-turn sink, the dispatch wrapper, and the shared idempotent destroy path that the shutdown handler drains. Flag off means byte-identical behavior. Size 350 to 500 lines. Risk medium (the teardown-deferral refactor and the turn demux). +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 on the cold path (see the scope table in Q7), asserted by tests. Size 200 to 300 lines. Highest correctness value; highest correctness risk. 3. **Daytona.** Remove the `isDaytona` gate after slices 1 and 2 have run in real use with no problems. Verify cookie-fetch session reuse and mounted cwd survival across a park. Small code, real operational risk (billed idle time, remote liveness). ## Flags and defaults @@ -180,10 +204,12 @@ Carried from architecture-notes.md; unchanged here: ## Verification plan -Before slice 1: run spike E5 (two prompts, one session) to confirm native memory and clean listener re-attach. +Before slice 1: run spike E5 (two prompts, one session) to confirm native memory and clean event demux across turns. Before slice 2: run spike E6 (hold a permission request, then respond) to confirm the parked prompt continues with 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. +Fingerprint contract (pinned after review): the history fingerprint is computed over the message array the server actually receives, which is the frontend's PRUNED array (the FE drops assistant turns that produced no answer part before sending). Unit tests pin this: a conversation containing an answer-less assistant turn must produce the same fingerprint as the pruned conversation the FE would send, and a fingerprint computed over the unpruned array must NOT match. If the FE pruning rule ever changes, these tests are the tripwire; the failure mode is a silent fall to cold replay, never a wrong continuation. + ## Out of scope - No wire, SDK, or frontend change. diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 663dfd8ed0..8cda06d394 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -2,10 +2,10 @@ Source of truth for progress. Keep this current. -## Current state (2026-07-07) +## Current state (2026-07-08) -- Phase: planning. Plan drafted and awaiting Mahmoud's review. -- Code: none written. Do not commit code from this workspace yet (another lane is in flight). +- Phase: implementation. Mahmoud approved the plan subject to the Codex xhigh review findings; all seven findings are folded into plan.md and architecture-notes.md (see the review changelog at the top of plan.md). +- 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. ## Decisions made @@ -13,7 +13,12 @@ Source of truth for progress. Keep this current. - 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 the conversation `session_id`, which already rides the wire. +- 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 @@ -34,7 +39,6 @@ Minor items to note (not blockers): ## Next steps -1. Mahmoud reviews the plan and the open questions. -2. Run spike E5 (two prompts, one session) before slice 1. -3. Run spike E6 (hold a permission request, then respond) before slice 2. -4. Implement slice 1 behind the flag. Verify flag-off is byte-identical. +1. Land slice 1 (`feat/session-keepalive-pool`) 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) with the flag on and off against a real playground conversation, confirming flag-off is byte-identical and watching the [HITL]/pool log lines. +3. Then consider Daytona (slice 3) and session resume (option 3) per the recorded order. From a6bec7e46cfdbe3c1e3aa1da196d9af9c136ae91 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 10:40:49 +0200 Subject: [PATCH 03/13] docs(design): fold the Codex confirmation-pass corrections into the keep-alive plan Pool-key project scope comes from the mount-sign response (no project id rides the wire); a session with no mount credentials never parks. The credential epoch hashes resolved secret values process-locally (no version identity exists on the wire), never logged or persisted. The stale park-mode note about client-tool loopback calls now matches the v1 gate scope (Claude ACP permission gates only). Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../projects/session-keepalive/architecture-notes.md | 6 +++--- .../projects/session-keepalive/open-questions.md | 4 ++-- .../agent-workflows/projects/session-keepalive/plan.md | 6 +++--- .../agent-workflows/projects/session-keepalive/status.md | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md index 015e211961..573d83a368 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -17,8 +17,8 @@ Today the runner destroys the sandbox and harness session at the end of every tu ## Design ### Pool and keying -- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `:` (amended after review: the caller-supplied `sessionId` alone must not be the key; the project scope comes from the request's authenticated context, the same values the mount-signing call uses, so two projects can never share a live harness process). States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. -- Credential epoch (amended after review): the park records the mount-credential expiry and a hash of the resolved credential identities. An expired or rotated epoch is treated as a fingerprint mismatch: evict and cold-start with fresh credentials. Parked sessions must not outlive their baked credentials. +- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `:` (amended after review: the caller-supplied `sessionId` alone must not be the key). Confirmation-pass correction: no project id rides the `/run` wire (the Python adapter forwards none; `server.ts` assumes its absence), so the project scope comes from the mount-sign RESPONSE (`mount.project_id`, which the runner's mount helper must stop discarding). No mount credentials means no safe project scope, so that session is NEVER parked. States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. +- Credential epoch (amended after review; corrected in the confirmation pass): the park records the mount-credential expiry plus a PROCESS-LOCAL hash over the actual resolved secret values and callback auth (runner memory only; never logged, persisted, or emitted). An identity hash cannot work: the wire carries raw values and no version identity, so a same-slug rotation would go undetected. An expired epoch or a changed value hash is treated as a fingerprint mismatch: evict and cold-start with fresh credentials. Parked sessions must not outlive their baked credentials. - Requests without a `sessionId` never park (non-playground callers keep today's semantics). ### Continuation versus cold decision @@ -43,7 +43,7 @@ F-040 (docs/design/agent-workflows/projects/qa/f040-park-terminal-plan.md) destr On approval: `respondPermission(parkedId, "once" | "reject")` -> the original prompt promise continues -> the new turn's emitter streams the remaining events -> the tool executes with its original byte-exact arguments. This removes the argument-drift and task-restart failure classes at the root. The exact-args decision-map machinery stays untouched as the cold fallback (approval TTL expired, restart, etc.). -Required changes in park mode: the pause controller must not fire the destroy callback (inject a park callback); do not abort `mcpAbort` (a paused client tool's loopback call must stay in flight); do not settle the paused call with `TOOL_NOT_EXECUTED_PAUSED` (it will actually run); add `resume()` clearing `pausedToolCallIds` so post-resume `tool_call_update` frames stream again (`shouldSuppressPausedToolCallUpdate`, `sandbox_agent.ts:180`). The "a pause sends NO harness reply" contract (`acp-interactions.ts:68-72`) is unchanged; the reply just arrives later on the same session. The interactions plane needs `resolveInteraction` for the parked token on resume (hook exists at `sandbox_agent.ts:850`), ordered against `cancelStaleInteractions` (`server.ts:275`). +Required changes in park mode (v1: Claude ACP permission gates only; corrected in the confirmation pass): the pause controller must not fire the destroy callback (inject a park callback); the environment's `mcpAbort` is simply not fired on a park because the environment stays alive (client-tool pauses themselves NEVER park in v1, see the scope note below, so no paused loopback call is ever held across a park); do not settle the paused call with `TOOL_NOT_EXECUTED_PAUSED` (it will actually run); add `resume()` clearing `pausedToolCallIds` so post-resume `tool_call_update` frames stream again (`shouldSuppressPausedToolCallUpdate`, `sandbox_agent.ts:180`). The "a pause sends NO harness reply" contract (`acp-interactions.ts:68-72`) is unchanged; the reply just arrives later on the same session. The interactions plane needs `resolveInteraction` for the parked token on resume (hook exists at `sandbox_agent.ts:850`), ordered against `cancelStaleInteractions` (`server.ts:275`). Note: `acp-fetch.ts` already disables undici header/body timeouts specifically so a paused HITL turn's held ACP connection survives human-timescale delays. The holding infrastructure exists. diff --git a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md index 3f62eefc99..92c3ad406b 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -12,6 +12,6 @@ None of these block starting slice 1. They refine defaults and edge behavior. Ea 5. **Supersede versus reject on a racing second turn.** The working default is supersede: if a second turn arrives while a session is busy, destroy and cold-start the new turn. The alternative is to reject the second turn. Supersede is simpler and matches "never fail a turn". Confirm supersede is acceptable. -6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. Note (2026-07-08 review fold): credential identity is handled separately by the credential epoch on the park record, not by the config fingerprint; secret VALUES stay out of any hash. +6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. Note (2026-07-08 review fold, corrected by the confirmation pass): credential rotation is handled by the credential epoch on the park record, not by the config fingerprint. Secret values stay out of the CONFIG fingerprint; the epoch itself uses a process-local hash over the resolved secret values (runner memory only, never logged, persisted, or emitted), because the wire carries raw values and no version identity, so an identity hash would miss a same-slug rotation. -Decisions folded from the 2026-07-08 Codex review (recorded, not open): slice 2 v1 parks Claude ACP permission gates only (plan.md Q7 scope table); listeners are session-lifetime with a current-turn demux; the pool key is project-scoped and parks carry a credential epoch; a resumed approval executes with the original turn's baked environment while the new turn owns streaming and tracing; a partial acquireEnvironment failure cleans up through the incrementally registered finalizers. +Decisions folded from the 2026-07-08 Codex review and its confirmation pass (recorded, not open): slice 2 v1 parks Claude ACP permission gates only (plan.md Q7 scope table); listeners are session-lifetime with a current-turn demux; the pool key is `:` with the project scope taken from the mount-sign response (`mount.project_id`), and a session with no mount credentials never parks; parks carry a credential epoch (mount expiry + process-local value hash); a resumed approval executes with the original turn's baked environment while the new turn owns streaming and tracing; a partial acquireEnvironment failure cleans up through the incrementally registered finalizers. diff --git a/docs/design/agent-workflows/projects/session-keepalive/plan.md b/docs/design/agent-workflows/projects/session-keepalive/plan.md index e4c3510d5b..588ade45ca 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/plan.md +++ b/docs/design/agent-workflows/projects/session-keepalive/plan.md @@ -2,7 +2,7 @@ - Status: plan approved by Mahmoud subject to the Codex xhigh review findings, which are folded in below. Implementation in progress (2026-07-08). - Owner input: Mahmoud. Author: Claude, 2026-07-07. Amended 2026-07-08 after the Codex xhigh plan review. -- Review changelog (2026-07-08): (1) slice 2 is scoped to Claude ACP permission gates only; a per-gate-type table below records what every other gate type does on a park. (2) The per-turn listener detach/attach design is replaced with session-lifetime listeners that demux into the active turn's sink. (3) The SIGTERM claim is corrected: the pool owns a complete idempotent destroy per session; `inFlightSandboxes` alone only destroys the sandbox. (4) The pool key is scoped by project, and a credential-epoch rule evicts parked sessions when credentials rotate. (5) A resumed approval executes with the original turn's baked environment; the new turn governs streaming and tracing. (6) A partial acquireEnvironment failure cleans up through the same finalizer list. (7) The history fingerprint is pinned to the pruned (sent) message array, with tests. +- Review changelog (2026-07-08): (1) slice 2 is scoped to Claude ACP permission gates only; a per-gate-type table below records what every other gate type does on a park. (2) The per-turn listener detach/attach design is replaced with session-lifetime listeners that demux into the active turn's sink. (3) The SIGTERM claim is corrected: the pool owns a complete idempotent destroy per session; `inFlightSandboxes` alone only destroys the sandbox. (4) The pool key is scoped by project, and a credential-epoch rule evicts parked sessions when credentials rotate. (5) A resumed approval executes with the original turn's baked environment; the new turn governs streaming and tracing. (6) A partial acquireEnvironment failure cleans up through the same finalizer list. (7) The history fingerprint is pinned to the pruned (sent) message array, with tests. Confirmation pass (same day): the pool-key project scope is sourced from the mount-sign response (no project id rides the wire), with a hard no-mount-no-park rule; the credential epoch uses a process-local hash over resolved secret values (no version identity exists on the wire); the stale park-mode note about client-tool loopback calls was corrected to match the v1 gate scope. - Read first: [architecture-notes.md](architecture-notes.md) (code-grounded research, built on here by reference, not repeated). - Why this exists: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). - Complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). @@ -21,9 +21,9 @@ This section answers each question directly. Short sentences, plain language, te Three pieces of work in the runner. -1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. The pool key is NOT the caller-supplied `session_id` alone: it is the project scope plus the conversation id (`:`, with the project id taken from the request's own authenticated scope, the same values the mount-signing call uses). Two callers from different projects who send the same `session_id` must never share a live harness process. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a credential epoch (below), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), a TTL timer, and a complete `destroy()` closure (Q4). The pool exposes: `get(key)`, `park(key, liveSession, ttl)`, `evict(key)`, `destroy(key)`, `destroyAll()`, and an LRU cap check. Every teardown trigger calls the session's one idempotent `destroy` path. +1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. The pool key is NOT the caller-supplied `session_id` alone: it is the project scope plus the conversation id (`:`). Confirmation-pass correction (2026-07-08): no project id rides the `/run` wire today (the Python adapter does not forward one, and `server.ts` assumes its absence), so the project scope comes from the mount-sign RESPONSE: the sessions API returns the mount object with its `project_id`, and the runner's mount helper (which today keeps only the credentials) is extended to surface it. If mount signing is unavailable (503, store unconfigured, ephemeral cwd fallback), there is no safe project key source, so that session is NEVER PARKED; it runs fully cold with today's teardown. Two callers from different projects who send the same `session_id` must never share a live harness process. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a credential epoch (below), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), a TTL timer, and a complete `destroy()` closure (Q4). The pool exposes: `get(key)`, `park(key, liveSession, ttl)`, `evict(key)`, `destroy(key)`, `destroyAll()`, and an LRU cap check. Every teardown trigger calls the session's one idempotent `destroy` path. - Credential epoch: a parked session baked its credentials (signed mount credentials, resolved secrets, tool-callback auth) at acquire time. A parked session must not outlive them. The park records a credential epoch (the mount-credential expiry timestamp, and a hash over the resolved secret and callback-auth identities). On the next request, if the epoch expired or the incoming request resolves to different credential identities, treat it as a fingerprint mismatch: evict, destroy, cold-start with fresh credentials. The idle TTL (60 s) is far shorter than the mount-credential lifetime, so this mostly matters for the longer approval TTL. + Credential epoch: a parked session baked its credentials (signed mount credentials, resolved secrets, tool-callback auth) at acquire time. A parked session must not outlive them. Confirmation-pass correction (2026-07-08): the wire carries raw secret VALUES and no version identity, so an identity hash cannot detect a same-slug rotation. The epoch is therefore the mount-credential expiry timestamp plus a PROCESS-LOCAL hash over the actual resolved secret values and tool-callback auth. That hash lives in runner memory only: it is never logged, never persisted, and never included in any emitted event or error. On the next request, if the epoch expired or the incoming request's value hash differs, treat it as a fingerprint mismatch: evict, destroy, cold-start with fresh credentials. The idle TTL (60 s) is far shorter than the mount-credential lifetime, so this mostly matters for the longer approval TTL. 2. A split of `runSandboxAgent` in `services/runner/src/engines/sandbox_agent.ts` into two parts: - `acquireEnvironment(request)`: everything that is session-scoped and expensive to build. This is roughly today's lines 328 to 699: sign mount credentials, derive the durable cwd, build the run plan, start the sandbox, mount the durable cwd, prepare the workspace, probe capabilities, build the internal tool MCP server, and call `createSession`. The output is an environment object that can serve many turns. The internal tool MCP server must live as long as the session, because its URL is baked into `sessionInit`. Acquire builds its finalizer list incrementally: each acquired resource registers its finalizer the moment it exists (sandbox started, cwd mounted, MCP server up, session created, temp dirs made). If a later acquire step throws, the environment's `destroy()` runs the finalizers registered so far, in reverse order, and the failure surfaces as it does today. This makes a half-built environment impossible to leak, and it is the same `destroy()` the pool uses later. diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 8cda06d394..19eca17108 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -5,6 +5,7 @@ Source of truth for progress. Keep this current. ## Current state (2026-07-08) - Phase: implementation. Mahmoud approved the plan subject to the Codex xhigh review findings; all seven findings are folded into plan.md and architecture-notes.md (see the review changelog at the top of plan.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. One fold only, per the agreed loop bound. - 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. From 9f04e1675608c7be7a1de4f8fa051ba732af170c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 12:36:13 +0200 Subject: [PATCH 04/13] docs(design): record slice 1 live QA results and the mounts-migration drift footgun Slice 1 measured on the dev box: cold 25.61 s, hit-continue 3.12 s, TTL expiry and cold fallback correct. Also records that a stale dev DB whose oss000000006 mounts migration predates the meta column makes mount signing 500, which keep-alive masks by design (no mount scope means never park); the migration fix belongs to that migration's owner, in a new migration. Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../agent-workflows/projects/session-keepalive/status.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 19eca17108..abcb1f8cd7 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -38,8 +38,13 @@ 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`) 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) with the flag on and off against a real playground conversation, confirming flag-off is byte-identical and watching the [HITL]/pool log lines. +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. From 1f2091ef09749f62ac98e5a5d0c5c63babd7e240 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 13:48:37 +0200 Subject: [PATCH 05/13] docs(design): rewrite keep-alive plan for context, trade-offs, and measured costs Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../projects/session-keepalive/README.md | 16 +- .../session-keepalive/architecture-notes.md | 356 +++++++++++++++--- .../session-keepalive/open-questions.md | 16 +- .../projects/session-keepalive/plan.md | 217 +++++------ .../projects/session-keepalive/status.md | 40 +- 5 files changed, 437 insertions(+), 208 deletions(-) diff --git a/docs/design/agent-workflows/projects/session-keepalive/README.md b/docs/design/agent-workflows/projects/session-keepalive/README.md index f9f629a3ec..66452b054c 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/README.md +++ b/docs/design/agent-workflows/projects/session-keepalive/README.md @@ -4,17 +4,17 @@ Keep the harness session alive for a TTL after a turn ends, so the next message ## Files -- [context.md](context.md) — why this exists, goals, non-goals, background, and how it relates to the other features. -- [plan.md](plan.md) — the plan. Contains the Q&A section answering Mahmoud's questions, the "could a minimal version work in an hour?" answer, the slice sizes and risks, flags, failure modes, and the verification plan. -- [status.md](status.md) — source of truth for progress, decisions, and the drift check of the research against current code. -- [open-questions.md](open-questions.md) — decisions that refine defaults and edge behavior; none blocks slice 1. -- [architecture-notes.md](architecture-notes.md) — the code-grounded research the plan builds on. Read this for the exact line references and the full design. +- [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. ## Start here -Read context.md, then plan.md. The plan's Q&A section and the one-hour assessment are the parts Mahmoud asked for. +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. +- [../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 index 573d83a368..8f41721614 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -1,78 +1,318 @@ -# Session keep-alive: architecture research notes +# Session keep-alive: how the runner works today, what changes, and why -Input for the plan-feature pass. Produced 2026-07-07 from a code-grounded design review of services/runner. Source analysis: [../approval-boundary/cold-replay-failure-report.md](../approval-boundary/cold-replay-failure-report.md) (Part 3, option 2). +This is the deep, code-grounded companion to [plan.md](plan.md). It has three parts: -## The idea in one paragraph +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. -Today the runner destroys the sandbox and harness session at the end of every turn. Keep-alive means: when a turn finishes, keep the session alive for a short time (a TTL, for example 60 seconds). If the next user message arrives inside that window, continue the same live session with just the new text. The harness keeps its own full memory (tool calls, results, thinking) because the process never died. If the window expires or anything mismatches, fall back to today's cold replay. Nothing can get worse than today. +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. -## Verified grounding +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. -- The runner is a long-lived Node daemon on :8765 (`src/server.ts:491`), one replica in every deployed topology (compose and Helm default). Per-process state already exists (`inFlightSandboxes` set, replica id, `owner:session:` affinity keys in `src/sessions/alive.ts:32-36`). An in-memory `Map` pool is viable today. -- The conversation key already rides the wire end to end: FE `agentRequest.ts:303` sends `session_id`, SDK `handler.py:253` forwards it, runner `protocol.ts:386` receives it. No wire change needed. -- `runSandboxAgent` (`src/engines/sandbox_agent.ts:321-1047`) builds sandbox, session, relay, prompt per request; the `finally` (1004-1047) unconditionally destroys everything. On approval pause, `PendingApprovalPauseController.pause()` (`pause.ts:24-29`) also destroys the session. -- The `sandbox-agent` package's `Session` object supports repeated `prompt()` calls, re-attachable `onEvent`/`onPermissionRequest` listeners (they return unsubscribe functions), and `respondPermission(permissionId, reply)` callable at any later time (pending requests park in `SandboxAgent.pendingPermissionRequests`). Nothing forces one prompt per session. -- The CLI path (`src/cli.ts`, local SDK adapter) is process-per-request; keep-alive is inert there by construction. +--- -## Design +# Part 1: How the runner works today -### Pool and keying -- New `src/engines/sandbox_agent/session-pool.ts`: `LiveSession` records keyed by `:` (amended after review: the caller-supplied `sessionId` alone must not be the key). Confirmation-pass correction: no project id rides the `/run` wire (the Python adapter forwards none; `server.ts` assumes its absence), so the project scope comes from the mount-sign RESPONSE (`mount.project_id`, which the runner's mount helper must stop discarding). No mount credentials means no safe project scope, so that session is NEVER parked. States: `busy` -> `idle` (TTL timer) or `awaiting_approval` (longer approval TTL) -> `destroyed`. -- Credential epoch (amended after review; corrected in the confirmation pass): the park records the mount-credential expiry plus a PROCESS-LOCAL hash over the actual resolved secret values and callback auth (runner memory only; never logged, persisted, or emitted). An identity hash cannot work: the wire carries raw values and no version identity, so a same-slug rotation would go undetected. An expired epoch or a changed value hash is treated as a fingerprint mismatch: evict and cold-start with fresh credentials. Parked sessions must not outlive their baked credentials. -- Requests without a `sessionId` never park (non-playground callers keep today's semantics). +## The runner is one long-lived process -### Continuation versus cold decision -Each parked session records two fingerprints: -1. `configFingerprint`: canonical-JSON hash over config-bearing request fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, systemPrompt, appendSystemPrompt, tools, skills, customTools, mcpServers, toolCallback.endpoint, permissions, sandboxPermission, harnessFiles, workflow revision id/version, is_draft). Exclude per-turn volatiles (messages, turnId, trace propagation, telemetry headers which rotate ~15 min, secret values). -2. `historyFingerprint`: ordered prior user-message texts plus ordered tool-call ids (both survive the egress/ingress round trip byte-stable), plus `promptCount`. Pinned contract (amended after review): computed over the message array the server receives, which is the frontend's PRUNED array (the FE drops answer-less assistant turns before sending). Unit tests pin the pruned-array contract so a future FE pruning change trips a test instead of silently breaking continuations; a fingerprint miss degrades to cold replay, never a wrong continuation. +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. -On an incoming request with a pool hit: -- Continue (normal): fingerprints match, tail is a fresh user message -> `session.prompt(newUserText)`. No `buildTurnText`. -- Continue (approval resume): fingerprints match, a parked pending-permission id exists, the new content is an approval envelope whose `toolCallId` matches the parked gate -> answer the parked RPC (below). -- Cold: anything else (mismatch, miss, dead, busy). Evict, destroy, run today's path. A validation failure must never fail the turn. +## What one turn builds today -### Turn-flow refactor -Split `runSandboxAgent` into: -- `acquireEnvironment(request)`: mount signing through `createSession` + MCP wiring (today lines 328-699). Session-scoped: sandbox handle, session handle, internal tool-MCP server (its URL is baked into sessionInit, so it must live as long as the session), relay dir, mounted cwd, workspace, skills dirs. Finalizers register incrementally as each resource is acquired; a mid-acquire failure runs the finalizers registered so far (reverse order) through the same `destroy()` the pool uses. A half-built environment cannot leak. -- `runTurn(env, request, emit, signal)`: per-turn otel run, fresh `PendingApprovalLatch`, fresh `ConversationDecisions`, swap the environment's current-turn sink to this turn's handlers, restart toolRelay, prompt, resolve usage, flush otel. +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: -Listener model (amended after review): `onEvent`/`onPermissionRequest` attach ONCE in `acquireEnvironment` for the session's lifetime and demux into a mutable `currentTurn` sink that `runTurn` swaps in and out. Per-turn detach/attach is wrong for this package: the listener registries are plain Sets, an event with no listener is silently dropped, and a permission request with no listener is CANCELLED, so any detach window is a drop/cancel window. Events arriving with no active turn hit an explicit between-turns handler (permission requests park in slice 2 or cancel by policy; stray events are logged and dropped deliberately). +- 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`. -### The approval interaction (the big win) -F-040 (docs/design/agent-workflows/projects/qa/f040-park-terminal-plan.md) destroyed the session on pause for three reasons: (a) the HTTP turn must end (Claude never resolves `prompt()` on an unanswered gate), (b) the sandbox would leak if the turn never ended, (c) the package blocks manual `session/cancel`, so destroy was the only clean cancel. A TTL park invalidates (b) and (c) and preserves (a): the turn still ends with `stopReason: "paused"` (the existing race at `sandbox_agent.ts:899-912` still works), but the `LiveSession` parks holding the still-pending `prompt()` promise and the unanswered permission id. +`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: -On approval: `respondPermission(parkedId, "once" | "reject")` -> the original prompt promise continues -> the new turn's emitter streams the remaining events -> the tool executes with its original byte-exact arguments. This removes the argument-drift and task-restart failure classes at the root. The exact-args decision-map machinery stays untouched as the cold fallback (approval TTL expired, restart, etc.). +``` +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`) +``` -Required changes in park mode (v1: Claude ACP permission gates only; corrected in the confirmation pass): the pause controller must not fire the destroy callback (inject a park callback); the environment's `mcpAbort` is simply not fired on a park because the environment stays alive (client-tool pauses themselves NEVER park in v1, see the scope note below, so no paused loopback call is ever held across a park); do not settle the paused call with `TOOL_NOT_EXECUTED_PAUSED` (it will actually run); add `resume()` clearing `pausedToolCallIds` so post-resume `tool_call_update` frames stream again (`shouldSuppressPausedToolCallUpdate`, `sandbox_agent.ts:180`). The "a pause sends NO harness reply" contract (`acp-interactions.ts:68-72`) is unchanged; the reply just arrives later on the same session. The interactions plane needs `resolveInteraction` for the parked token on resume (hook exists at `sandbox_agent.ts:850`), ordered against `cancelStaleInteractions` (`server.ts:275`). +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.) -Note: `acp-fetch.ts` already disables undici header/body timeouts specifically so a paused HITL turn's held ACP connection survives human-timescale delays. The holding infrastructure exists. +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. -Scope (amended after review): the park-and-answer path applies to Claude ACP permission gates ONLY in v1. The other three gate mechanisms stay cold, each for a structural reason: Pi custom-tool gates block on the file relay inside the sandbox with their own deadline (`RELAY_TIMEOUT_MS` 60 s default; the dispatch shim polls a response file), Pi builtin gates block synchronously inside `extensions/agenta.ts` on `relayPermissionCheck`, and a client-tool MCP pause deterministically ABORTS the in-flight HTTP request (`tool-mcp-http.ts`), so by turn end there is nothing held to answer. The dispatch takes the park-and-answer path only for a parked Claude ACP permission id; any other pending-gate shape evicts to cold. Tests assert the non-Claude gates stay cold. The full consequence table is in plan.md Q7. +## What one turn tears down today -Execution context on resume (amended after review): the ORIGINAL turn's baked environment executes the tool (its signed mount credentials, resolved secrets, callback auth, harness env, sandbox); the NEW turn's context owns streaming and tracing (otel run, emitter, interaction resolve). The credential epoch bounds staleness: expired or rotated credentials evict instead of resuming. +`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: -### Lifecycle and safety -- Flags: `AGENTA_RUNNER_SESSION_KEEPALIVE` (default off), `AGENTA_RUNNER_SESSION_TTL_MS` (60000), `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (600000), `AGENTA_RUNNER_SESSION_POOL_MAX` (~8). -- Eviction: LRU on idle entries when full; never evict busy. Parking is best-effort; pool full -> tear down as today. -- Teardown triggers: TTL expiry, fingerprint mismatch, credential-epoch expiry, explicit stop (`POST /kill` drains the pool), runner shutdown, rejected parked prompt promise. -- Runner shutdown (corrected after review): `inFlightSandboxes` is not sufficient; `destroyInFlightSandboxes` only calls `destroySandbox` and skips the relay stop, `mcpAbort`, `closeToolMcp`, `destroySession`, dispose, unmount, and temp-dir removal. Each `LiveSession` carries the complete idempotent `destroy()` (all the finalizers the old `finally` ran), and the SIGTERM/SIGINT path drains the pool through `pool.destroyAll()` (timeout-bounded). `inFlightSandboxes` stays as a second line of defense for the sandbox handle only. -- Idle cost: local = host RAM only (a few hundred MB per session: daemon + adapter + harness processes; the 2026-07-06 child-process-leak notes in the finally block list exactly what to track). Daytona = billed idle time; existing backstops (15-min auto-stop, ephemeral auto-delete, `provider.ts:41-98`) still reap leaks. Enable local-only first. +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. -### Failure modes (detect -> degrade, never fail the turn) -| Failure | Detection | Fallback | +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. + +## 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. Options here were: (a) mutate the live session to the new config, (b) continue with the old config and ignore the change, or (c) evict and cold-start with the new config. We chose **(c)**. A running harness cannot be reconfigured in place (its model, prompt, and tools were baked into the process at `createSession`), so (a) is not possible without tearing the process down anyway. Option (b) would silently ignore the user's edit, which is wrong. Option (c) evicts the parked session, destroys it, and cold-starts a fresh one with the new config. The user gets exactly what they configured, at the cost of one cold turn. A config change is a cold turn, by design. + +**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. + +## 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. + +**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). Options were: (a) reject the second turn with an error, or (b) supersede: destroy the busy session and cold-start the second turn fresh. We chose **(b), supersede**. It is simpler and it honors the rule "never fail a turn." The second turn runs; it just runs cold instead of continuing. The first turn's result is abandoned, which is the correct outcome when the user has moved on. 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: if a request arrives for a session already marked busy, the runner supersedes. + +**Why not a real lock, like the rest of the backend uses.** The backend has a locking facility (for example around sessions) that could serialize turns on a conversation. We considered using it. We chose not to, for two reasons. First, keep-alive is single-replica and single-threaded, so the only race is inside one process, and a `busy` flag settles it without any distributed machinery. Second, the desired behavior on a race is not "block the second turn until the first finishes"; it is "let the second turn win, cold." A lock would make the second caller wait for a turn whose result they no longer want. The long-term picture is different: once the runner is multi-replica, or once turns can be parked durably on the interactions plane (Decision covered in Part 4), a real cross-process lock or an affinity route becomes the right tool. For this single-replica, in-memory feature, the flag is the honest 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). + +## Decision 6: the approval win, for Claude today and Pi later + +This is the decision Mahmoud asked to be rewritten to cover both harnesses. The full context is the four-gate table in Part 1. Here is what keep-alive does with each gate, why, and what it would take to extend it. + +**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 60-second deadline (`RELAY_TIMEOUT_MS`). 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. +- **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 Pi parkable (the future path, not v1).** The Pi gates are unparkable because the wait lives inside the sandbox on a bounded file poll, not in the runner as an answerable promise. Two things would have to change. First, the in-sandbox wait would need to be unbounded or resumable, instead of a 60-second fail-closed deadline; a park can outlast 60 seconds, so the relay would have to hold (or re-establish) the pending state rather than time out and error. Second, the pending decision would have to move into a runner-held handle, the way the Claude ACP request already is, so the runner has something to answer after the turn ends. That is a relay redesign, not a keep-alive change, so it is out of scope for v1 and recorded as future work. The honest v1 statement is: keep-alive makes the Claude approval path reliable now, and leaves a clear, scoped path to do the same for Pi later. Until then, the consequence for a Pi user is spelled out plainly: their approvals keep working through the cold decision-map replay, with the same drift risk that path has always had. + +**What park mode has to change in the code (Claude path).** 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 + +Mahmoud asked for real numbers, not guesses. These were measured 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 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 new turn | -| Continuation throws mid-turn | try/catch in runTurn | destroy; retry once cold | -| Client disconnects | session-owned runs already survive (`server.ts:237-246`) | on abort: destroy, don't park | -| Approval reply after approval-TTL | pool miss | cold replay + existing decision-map path | -| Multi-replica miss (future) | pool miss | cold replay; later route via `owner:session` affinity | - -### Slices -1. Keep-alive across normal turn boundaries, local only, flag off by default. ~350-500 lines: session-pool.ts, split of sandbox_agent.ts, dispatch wrapper in server.ts, tests through the existing `SandboxAgentDeps` / `createAgentServer(run)` seams. Runner-only; zero wire/SDK/FE change; flag off = byte-identical behavior. -2. Keep-alive across approval pauses, Claude ACP permission gates only. ~200-300 lines: pause.ts park mode, respondPermission resume, interaction resolve ordering. Pi relay gates, Pi builtin gates, and client-tool MCP pauses stay cold (asserted by tests). Highest correctness value. -3. Daytona: remove the `isDaytona` gate after slices 1-2 are tested and have run in real use without problems; verify cookie-fetch session reuse and tunnel-mounted cwd survival across a park. Real operational risk is billed idle time and remote liveness. - -## Relation to session resume (option 3, ../harness-session-resume/plan.md) -Independent and complementary. Keep-alive covers the fast conversational loop and the approval window with zero storage work; session resume covers long gaps, runner restarts, and replica moves via the harness's own session files plus `session/load`. Build order: keep-alive slice 1 -> slice 2 -> session-resume slice A; they do not block each other. +| 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. A Daytona sandbox has a 15-minute idle auto-stop and ephemeral auto-delete (`provider.ts`) as a leak backstop. 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.** 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 is 1 vCPU, 1 GiB RAM, 3 GiB disk, which runs at 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/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md index 92c3ad406b..fbf89fa55d 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -1,17 +1,17 @@ # Session keep-alive: open questions -None of these block starting slice 1. They refine defaults and edge behavior. Each states a working default so the plan can proceed if Mahmoud does not weigh in. +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.** 60 seconds is the working default. Longer means more follow-up messages land on the live session; longer also means more idle RAM held per parked session. Is 60 seconds right for the playground's typical pause between messages? +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.** 10 minutes is the working default. This is how long a parked approval holds its session and permission request open while it waits for a human. Longer helps a human who steps away; longer also holds idle resources. Is 10 minutes right, and should it differ for local versus Daytona later? +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. -3. **Pool cap default.** About 8 sessions is the working default. This bounds total idle RAM on one replica. What is the realistic concurrent-conversation count per replica we should size for? + 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"). -4. **History fingerprint over the pruned array.** DECIDED (2026-07-08 review fold): the fingerprint is computed over the sent (pruned) array, and unit tests pin the contract so a future frontend pruning change trips a test instead of silently invalidating continuations. A miss degrades to cold replay, never a wrong continuation. +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. -5. **Supersede versus reject on a racing second turn.** The working default is supersede: if a second turn arrives while a session is busy, destroy and cold-start the new turn. The alternative is to reject the second turn. Supersede is simpler and matches "never fail a turn". Confirm supersede is acceptable. +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. -6. **Config fingerprint field list.** The plan hashes config-bearing fields (harness, sandbox, model, provider, deployment, endpoint, credentialMode, agentsMd, system prompts, tools, skills, custom tools, MCP servers, permissions, sandbox permission, harness files, workflow revision id and version, is_draft) and excludes per-turn volatiles (messages, turnId, 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. Note (2026-07-08 review fold, corrected by the confirmation pass): credential rotation is handled by the credential epoch on the park record, not by the config fingerprint. Secret values stay out of the CONFIG fingerprint; the epoch itself uses a process-local hash over the resolved secret values (runner memory only, never logged, persisted, or emitted), because the wire carries raw values and no version identity, so an identity hash would miss a same-slug rotation. +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. -Decisions folded from the 2026-07-08 Codex review and its confirmation pass (recorded, not open): slice 2 v1 parks Claude ACP permission gates only (plan.md Q7 scope table); listeners are session-lifetime with a current-turn demux; the pool key is `:` with the project scope taken from the mount-sign response (`mount.project_id`), and a session with no mount credentials never parks; parks carry a credential epoch (mount expiry + process-local value hash); a resumed approval executes with the original turn's baked environment while the new turn owns streaming and tracing; a partial acquireEnvironment failure cleans up through the incrementally registered finalizers. +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 index 588ade45ca..a7d3540bcb 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/plan.md +++ b/docs/design/agent-workflows/projects/session-keepalive/plan.md @@ -1,218 +1,171 @@ # Session keep-alive: plan -- Status: plan approved by Mahmoud subject to the Codex xhigh review findings, which are folded in below. Implementation in progress (2026-07-08). -- Owner input: Mahmoud. Author: Claude, 2026-07-07. Amended 2026-07-08 after the Codex xhigh plan review. -- Review changelog (2026-07-08): (1) slice 2 is scoped to Claude ACP permission gates only; a per-gate-type table below records what every other gate type does on a park. (2) The per-turn listener detach/attach design is replaced with session-lifetime listeners that demux into the active turn's sink. (3) The SIGTERM claim is corrected: the pool owns a complete idempotent destroy per session; `inFlightSandboxes` alone only destroys the sandbox. (4) The pool key is scoped by project, and a credential-epoch rule evicts parked sessions when credentials rotate. (5) A resumed approval executes with the original turn's baked environment; the new turn governs streaming and tracing. (6) A partial acquireEnvironment failure cleans up through the same finalizer list. (7) The history fingerprint is pinned to the pruned (sent) message array, with tests. Confirmation pass (same day): the pool-key project scope is sourced from the mount-sign response (no project id rides the wire), with a hard no-mount-no-park rule; the credential epoch uses a process-local hash over resolved secret values (no version identity exists on the wire); the stale park-mode note about client-tool loopback calls was corrected to match the v1 gate scope. -- Read first: [architecture-notes.md](architecture-notes.md) (code-grounded research, built on here by reference, not repeated). +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). -- Complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). +- The complementary feature: [../harness-session-resume/plan.md](../harness-session-resume/plan.md) (option 3). ## What this feature is -Today the runner destroys the sandbox and the harness session at the end of every turn. Keep-alive changes one thing: when a turn ends, the runner keeps the session alive for a short time (a TTL). If the next message arrives inside that window, the runner continues the same live session and sends only the new user text. The harness keeps its full native memory because the process never died. If the window expires, or anything does not match, the runner falls back to today's cold replay. Nothing can get worse than today. - -The feature is flag-gated and local-only first. It changes runner code only. There is no wire change, no SDK change, and no frontend change. With the flag off, behavior is byte-identical to today. - -## Q&A: Mahmoud's questions, answered plainly +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. -This section answers each question directly. Short sentences, plain language, technical terms where they are exact. +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. -### Q1. How would it be implemented? (files, the acquireEnvironment/runTurn split, the pool) +## Q&A: Mahmoud's questions, answered -Three pieces of work in the runner. +Each answer is direct. For the full reasoning behind a decision, the referenced section of architecture-notes.md carries the options and trade-offs. -1. A new pool file: `services/runner/src/engines/sandbox_agent/session-pool.ts`. It holds a `Map`. The pool key is NOT the caller-supplied `session_id` alone: it is the project scope plus the conversation id (`:`). Confirmation-pass correction (2026-07-08): no project id rides the `/run` wire today (the Python adapter does not forward one, and `server.ts` assumes its absence), so the project scope comes from the mount-sign RESPONSE: the sessions API returns the mount object with its `project_id`, and the runner's mount helper (which today keeps only the credentials) is extended to surface it. If mount signing is unavailable (503, store unconfigured, ephemeral cwd fallback), there is no safe project key source, so that session is NEVER PARKED; it runs fully cold with today's teardown. Two callers from different projects who send the same `session_id` must never share a live harness process. A `LiveSession` record keeps the live sandbox handle, the live ACP session handle, the internal tool MCP server closer, the relay directory, the mounted cwd, the two fingerprints (config and history), a credential epoch (below), a state (`busy`, `idle`, `awaiting_approval`, `destroyed`), a TTL timer, and a complete `destroy()` closure (Q4). The pool exposes: `get(key)`, `park(key, liveSession, ttl)`, `evict(key)`, `destroy(key)`, `destroyAll()`, and an LRU cap check. Every teardown trigger calls the session's one idempotent `destroy` path. +### Q1. How would it be implemented? (files, the acquire/run split, the pool) - Credential epoch: a parked session baked its credentials (signed mount credentials, resolved secrets, tool-callback auth) at acquire time. A parked session must not outlive them. Confirmation-pass correction (2026-07-08): the wire carries raw secret VALUES and no version identity, so an identity hash cannot detect a same-slug rotation. The epoch is therefore the mount-credential expiry timestamp plus a PROCESS-LOCAL hash over the actual resolved secret values and tool-callback auth. That hash lives in runner memory only: it is never logged, never persisted, and never included in any emitted event or error. On the next request, if the epoch expired or the incoming request's value hash differs, treat it as a fingerprint mismatch: evict, destroy, cold-start with fresh credentials. The idle TTL (60 s) is far shorter than the mount-credential lifetime, so this mostly matters for the longer approval TTL. +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. -2. A split of `runSandboxAgent` in `services/runner/src/engines/sandbox_agent.ts` into two parts: - - `acquireEnvironment(request)`: everything that is session-scoped and expensive to build. This is roughly today's lines 328 to 699: sign mount credentials, derive the durable cwd, build the run plan, start the sandbox, mount the durable cwd, prepare the workspace, probe capabilities, build the internal tool MCP server, and call `createSession`. The output is an environment object that can serve many turns. The internal tool MCP server must live as long as the session, because its URL is baked into `sessionInit`. Acquire builds its finalizer list incrementally: each acquired resource registers its finalizer the moment it exists (sandbox started, cwd mounted, MCP server up, session created, temp dirs made). If a later acquire step throws, the environment's `destroy()` runs the finalizers registered so far, in reverse order, and the failure surfaces as it does today. This makes a half-built environment impossible to leak, and it is the same `destroy()` the pool uses later. - - `runTurn(env, request, emit, signal)`: everything that is per-turn. This is roughly today's lines 712 to 986: start a fresh otel run, create a fresh `PendingApprovalLatch` and `ConversationDecisions`, point the environment's turn sink at this turn's handlers (see the listener model below), start the tool relay, send the prompt, resolve usage, and finish and flush the trace. On a continuation the prompt is just the new user text, so `buildTurnText` never runs. +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. - Listener model (amended after review): the environment attaches `onEvent` and `onPermissionRequest` exactly once, in `acquireEnvironment`, for the life of the session. Those session-lifetime listeners demux into a mutable current-turn sink: a `env.currentTurn` reference that `runTurn` swaps in at turn start and clears at turn end. The earlier idea (detach turn N's listeners, attach turn N+1's) is wrong for this package: the `sandbox-agent` listener registries are plain Sets, an event that fires with no listener is silently dropped, and a permission request that arrives with no listener is CANCELLED. Any detach/attach window between turns is therefore a drop/cancel window. With session-lifetime listeners there is no window. Events that arrive while no turn is active (between turns, or after a park) go to a small between-turns handler on the environment: permission requests park (slice 2) or cancel-by-policy, stray events are logged and dropped by decision rather than by accident. +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, when the flag is on, the wrapper checks the pool. On a hit where both fingerprints match and the tail is a fresh user message, it calls `runTurn` on the live environment. On a hit where the new content is an approval decision that matches a parked gate, it answers the parked permission request (slice 2). On a miss, a mismatch, a busy session, or a dead session, it acquires a new environment and runs today's path. A validation failure must never fail the turn. It degrades to cold replay. +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` for the engine and `createAgentServer(run)` for the HTTP layer. No live harness is needed for the unit tests. +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) -Honest sizes and risk per slice: - | Slice | Files touched | Rough size | Risk | |---|---|---|---| -| 1. Keep-alive across normal turns | new `session-pool.ts`; split in `sandbox_agent.ts`; dispatch in `server.ts`; tests | 350 to 500 lines | Medium | -| 2. Keep-alive across approval pauses | `pause.ts`; resume wiring in `sandbox_agent.ts`; client-tool seam; interaction ordering; tests | 200 to 300 lines | High on correctness | -| 3. Daytona | remove the `isDaytona` gate; 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. They are a few dozen lines and carry almost no 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 genuinely risky parts are two, and neither is the pool: +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. **The teardown-deferral refactor (slice 1).** Today the whole cleanup lives in one `finally` block (`sandbox_agent.ts:1004-1047`). A `finally` runs on every exit, so teardown is guaranteed. Keep-alive removes that guarantee. It moves cleanup out of the `finally` and hands it to a timer and a pool. So every teardown trigger has to be re-proven to fire, and the shared `destroy` path has to be idempotent because the sandbox may already be gone. The exact cleanup steps that must survive are the ones added on the 2026-07-06 child-process-leak incident: graceful `session/cancel`, `mcpAbort.abort()`, `closeToolMcp`, `destroySession`, `destroySandbox`, `dispose`, unmount the durable cwd, and remove the temp dirs. Getting this wrong leaks sandboxes and reparented ACP child processes, which is the same class of bug that incident fixed. +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. **The turn demux (slice 1, and again in slice 2).** Each turn's handlers close over turn-scoped state (the otel run, the pause controller, the latch, the decisions, the responder). 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 dropped, and a permission request with no listener is CANCELLED. So the design uses session-lifetime listeners attached once in `acquireEnvironment`, demuxing into a mutable `currentTurn` sink that `runTurn` swaps in and out (see Q1). The risk moves from "did we detach in time" to "does every event route to the right turn's closures", which is testable with a fake session: two sequential turns must each see only their own events, and an event fired between turns must hit the between-turns handler, never a dead turn's closures. +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 touches the pause path where several existing rules interact: the F-024 clobber rule, the pause-time sweep of orphaned tool calls, and the "a pause sends no harness reply" contract. Changing pause from "destroy the session" to "park and hold the permission request" has to preserve all three while adding a new `resume()` step. +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. -### Q3. How do we keep it alive? (what "alive" means, what it costs while idle) +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. -"Alive" means these processes and objects stay running between turns: +### Q3. What does "keep alive" cost while idle? (measured memory and CPU) -- the `sandbox-agent` daemon process, -- the ACP adapter subprocess it spawned (`claude-agent-acp` or `pi-acp`), -- the harness process the adapter drives, -- the ACP session object bound in the daemon's session registry, -- the internal tool MCP server (its URL is baked into `sessionInit`, so it must outlive the session), -- the relay directory, the mounted durable cwd, and the geesefs mount. +"Alive" means one full process tree stays running between turns, per session: -While idle, nothing is executing. No prompt is running, so there is no CPU cost beyond the idle footprint. The cost is memory: +``` +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) +``` -- **Local: host RAM only.** A few hundred megabytes per parked session (the daemon plus the adapter plus the harness). No money is spent. The pool cap (about eight sessions) bounds the total. -- **Daytona: billed wall-clock time.** An idle remote sandbox costs money for as long as it is alive. This is why slice 3 is gated behind local success. The existing 15-minute auto-stop and ephemeral auto-delete backstops (`provider.ts`) still reap leaks, but they are a safety net, not the primary control. +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`. -### Q4. How do we auto-kill it? (TTL, LRU, all teardown triggers, SIGTERM, and the expired approval park) +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): -Every teardown trigger calls the same idempotent `destroy(sessionId)` path, which runs the full cleanup listed in Q2. +- **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). -Triggers: +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. -- **Idle TTL expiry.** An idle session gets a timer (default 60 seconds). When it fires, destroy. -- **Approval TTL expiry.** A session parked on an approval gets a longer timer (default 10 minutes). When it fires, destroy the parked session and abandon the held permission request. This degrades, it does not fail: the frontend still holds the approval prompt, and when the human clicks, the frontend resends the conversation with the approval envelope, the request misses the pool, and today's cold decision-map path answers it. So an expired approval park falls back to exactly today's behavior. -- **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.** On the next request, if either fingerprint does not match, evict and destroy the parked session and run the cold path. -- **Explicit stop.** `POST /kill` drains the whole pool. -- **Runner shutdown (SIGTERM or SIGINT).** Corrected after review: the `inFlightSandboxes` registry is NOT enough, because `destroyInFlightSandboxes` only calls `destroySandbox`. It does not stop the relay, abort `mcpAbort`, close the internal tool MCP server, call `destroySession`, dispose the daemon handle, unmount the durable cwd, or remove the temp dirs. Instead, each `LiveSession` carries the complete idempotent `destroy()` built up during `acquireEnvironment` (every finalizer the old `finally` block ran), and the shutdown handler drains the pool through `pool.destroyAll()`, timeout-bounded the same way `destroyInFlightSandboxes` is. Parked sessions stay registered in `inFlightSandboxes` too, as a second line of defense for the sandbox itself, but the pool's `destroy()` is the authoritative cleanup. On a hard `SIGKILL` or OOM the process dies with its local child processes; the Daytona auto-stop backstop covers the remote case the signal can never reach. -- **Client disconnect.** On abort, destroy, do not park. A session-owned run already survives disconnect during a turn (`server.ts:237-246`), but a disconnect means the turn is 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 (detected by a liveness probe on the next acquire), or a continuation that throws mid-turn all evict and fall back to cold replay. A mid-turn continuation failure retries once cold. +### Q4. How is it auto-killed, and how are edge cases handled? (TTL, LRU, teardown, SIGTERM, expired approvals) -### Q5. If we have keep-alive, how do sessions connect to it? (the pool key, and the relation to option 3) +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. -The pool key is the conversation `session_id`. The frontend mints it once per chat tab and sends it on every request. It survives the whole journey without a wire change: frontend `agentRequest.ts` sends it, the SDK `handler.py` forwards it, and the runner receives it as `request.sessionId` (`protocol.ts:385`, resolved by `resolveRunSessionId`). So a follow-up message in the same conversation carries the same key, and the runner finds the parked session under that key. - -Keep-alive and session resume (option 3) are different memories and they compose. Neither replaces the other: - -- **Keep-alive is memory within the TTL window, on the same replica.** The live process still holds the full native context (tool calls, results, and thinking). It is the fast path. It costs idle resources while it holds the session. -- **Session resume is memory across restarts, long gaps, and replica moves.** It reloads the harness's own session files with `session/load` after the process is gone. It costs storage and a load per turn, but zero idle resources. - -The two form a three-tier fallback keyed on the same `session_id`: +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. -1. Pool hit inside the TTL: continue the live session (keep-alive). Highest fidelity, fastest. -2. Pool miss but a recorded harness session id and the files exist: reload with `session/load` (session resume). Full fidelity, medium cost. -3. Both miss: cold replay of the flattened transcript (today's path). Always available, always correct. +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. -### Q6. Before or after option 3 (session resume)? (recommendation with reasons) +### Q5. How do sessions connect to keep-alive? (the pool key, and the relation to option 3) -Build keep-alive before session resume. Order: keep-alive slice 1, then keep-alive slice 2, then session-resume slice A. +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. -Reasons: +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"). -- Keep-alive removes the two production approval failures immediately. Both failures in the report (argument drift and task restart) come from destroying the session on approval. Slice 2 holds the session and its permission request open, so the tool runs with its original byte-exact arguments and the model never re-issues the call. That is the acute pain, and keep-alive kills it. -- Keep-alive is runner-only. It changes no wire field, no SDK, no frontend, and no storage. That is the smallest blast radius of the two features. -- Keep-alive has no upstream blocker. Session resume needs a `session/load` call that `sandbox-agent` 0.4.2 does not forward through its managed session API, so it needs a `pnpm patch` or the raw ACP passthrough first. Keep-alive needs none of that. -- Keep-alive establishes the seams session resume reuses: the config and history fingerprints, and the "skip `buildTurnText` on a continuation" branch. Session resume slice A reuses both. +### Q6. Before or after option 3 (session resume)? -Session resume is still the target architecture, because it is the only path that restores full fidelity across restarts, replica moves, and long gaps. Build it next, not first. +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 approval request reaches the same UI. The human approves or denies the same way. What changes is what the runner does after the click. - -Today, after the click: the runner destroyed the session when it paused, so it cold-starts a fresh session, replays a flattened transcript, and the approval waits in a decision map keyed on the tool name plus the canonical JSON of the exact arguments. A fresh model has to re-issue the tool call. If its regenerated arguments match the stored key, the call runs. If not, the gate parks again and the human sees a new prompt. This is where both production turns failed. +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. -With keep-alive on the live path (inside the approval TTL): the parked `LiveSession` still holds the still-pending permission request and the suspended `prompt()` promise. The runner calls `session.respondPermission(parkedId, "once" | "reject")`. The original prompt continues. The tool executes with its original byte-exact arguments. The new turn's emitter streams the remaining events. No model re-issues anything, so argument drift and task restart cannot happen. +**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. -Both paths stay. The live path runs inside the approval TTL. The cold path runs when the approval TTL expired, the runner restarted, or the pool missed, and it uses today's decision-map machinery unchanged. So keep-alive makes the common case reliable and leaves the fallback exactly as correct as it is now. +| 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 | -Which approval phases change and which do not: +**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. -| Phase | Today | With keep-alive | Changed? | -|---|---|---|---| -| Model calls a tool, gate raised | `onPermissionRequest` / relay gate fires | same | No | -| Runner emits `interaction_request`, records the interaction, sends the approval part to the UI | as today | as today | No | -| Turn ends with `stopReason: "paused"`, egress emits `finish` | the pause race ends the turn | the pause race still ends the turn (the session parks instead of being destroyed) | No (turn still ends) | -| Human clicks approve or deny in the UI | as today | as today | No | -| Frontend resends the conversation with the approval envelope | as today | as today (no frontend change) | No | -| Runner acts on the decision | destroy, cold-start, replay, model re-issues the call, match on the decision map | live path: answer the parked permission request, original call runs with exact args; cold path: today's decision map | Yes (this is the whole win) | -| Tool executes, output streams, UI part flips to output-available | reached only after a possible re-park loop | reached in one round on the live path; cold path unchanged | Reliability improves; end state is the same | - -The frontend approval part lifecycle (input-available, approval-requested, approval-responded, output-available) is unchanged. On the live path the transition from approval-responded to output-available becomes reliable and single-round. On the cold path it stays exactly as today, including the possible re-park. The durable interaction rows are unchanged too: create on pause (as today), resolve on the decision (the `onResolveInteraction` hook at `sandbox_agent.ts:850`). One new ordering rule is needed: on the live resume path, a new turn's `cancelStaleInteractions` (`server.ts:275`) must not cancel the interaction the runner is about to resolve on the same session. - -### Slice 2 scope: which gate types park (amended after review) - -The runner has four distinct approval-gate mechanisms, and only one of them holds a promise the runner can answer later. Slice 2 v1 parks that one and leaves the other three on the cold path, explicitly: - -| Gate type | How it pauses today | Slice 2 v1 | What staying cold means | -|---|---|---|---| -| Claude ACP permission gate | `onPermissionRequest` fires; the pending request parks in the daemon (`pendingPermissionRequests`); `respondPermission(id, reply)` is callable at any later time | **Parks.** The `LiveSession` holds the pending permission id and the suspended `prompt()` promise; on a validated resume the runner calls `respondPermission` | n/a (this is the live path) | -| Pi custom-tool gate (file relay) | the tool call blocks inside the sandbox on the relay response file, with a hard deadline (`RELAY_TIMEOUT_MS`, default 60 s, `relay.ts`; deadline loop in the dispatch shim) | **Stays cold.** The session is not parked for resume-by-answer; the pause tears down or times out as today | Identical to today: the relay deadline expires, the turn ends paused, the approval resumes through the cold decision map. A parked live session cannot answer it because the blocked side is a file-poll inside the sandbox with its own 60 s clock, not a runner-held promise | -| Pi builtin gate (extension) | the `pi.on("tool_call")` hook in `extensions/agenta.ts` blocks synchronously on `relayPermissionCheck` until the relay answers or times out | **Stays cold.** Same as the custom-tool relay: the block lives inside the Pi process | Identical to today. The synchronous block cannot survive a turn boundary, so there is nothing to park | -| Client-tool MCP pause | a paused client tool emits no JSON-RPC result and deterministically ABORTS the in-flight HTTP request to the internal MCP server (`tool-mcp-http.ts`) | **Stays cold.** The abort has already destroyed the in-flight call by the time the turn ends; there is no held request to answer | Identical to today: the client tool resumes through the cold path. Parking these needs a redesign of the MCP pause (hold the HTTP response open instead of aborting), which is out of scope for v1 | +**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. -Consequence: in v1, the live approval win applies to Claude harness runs gated through ACP permissions. Pi runs and client-tool pauses keep exactly today's behavior, no better and no worse. The dispatch must therefore only take the park-and-answer path when the parked gate is a Claude ACP permission id; any other pending-gate shape on a parked session is a mismatch that evicts to cold. A test asserts this. +**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." -### Whose context does a resumed approval execute with? (amended after review) +### Q8. Daytona (slice 3): cost, and where the difference lives -Decision: the ORIGINAL turn's baked environment executes the tool; the NEW turn's context governs streaming and tracing. +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. -Concretely: when the runner answers a parked permission request, the tool call that then executes inside the live session runs with everything the original `acquireEnvironment` baked in: the original signed mount credentials, resolved secrets, tool-callback endpoint and auth, harness env, and sandbox. The resume request does not re-bake any of that into the live session (it cannot; the harness process already holds it). What the new turn owns is the egress side: the new turn's otel run traces the resumed events, the new turn's emitter streams them, and the new turn's interaction hooks resolve the durable interaction row. The credential-epoch rule above bounds the staleness: if the original credentials expired or rotated before the resume arrived, the pool evicts instead of resuming, and the cold path re-bakes everything fresh. +**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). -### Q8. Daytona (slice 3) +**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: -Implement Daytona only after slices 1 and 2 are tested and have run in real use without problems. Enable local-only first. +- 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. -The reason is cost and remote failure modes. A parked local session costs host RAM. A parked Daytona sandbox costs billed wall-clock time for as long as it is alive, and remote liveness adds failure classes the local path does not have (tunnel drops, cookie-fetch session reuse, mounted cwd survival across a park). The existing 15-minute auto-stop and ephemeral auto-delete backstops still reap leaks, but they are a safety net, not a reason to enable Daytona keep-alive early. Turn Daytona on only after the local path has run in real use with no problems. +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? -Two different "minimal" versions, and the honest answers differ. +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 spike E5 from the failure report: in a test, drive one `sandbox-agent` `Session` with two sequential `prompt()` calls. 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 validates the core mechanic and de-risks slice 1. Spike E6 does the same for slice 2: raise a gate, hold the permission request for a minute, then `respondPermission(id, "once")`, and show the original prompt continues with the original arguments. Both spikes are legitimate stepping stones. +**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 the teardown-deferral refactor and the listener re-attachment (see Q2). You cannot get continue-on-match working without deferring teardown, and deferring teardown is exactly the risky part. A one-hour hack would either skip the idempotent shared `destroy` path (which reintroduces the 2026-07-06 process-leak class) or skip detaching the previous turn's listeners (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 version. - -Recommendation: spend the first hour on spike E5 (and E6 if time allows). Then build slice 1 properly. The spike is a stepping stone. The hack is not. - -## Slices (summary; full detail in architecture-notes.md) - -1. **Keep-alive across normal turns.** Local only, flag off by default, runner-only. Pool (project-scoped key, credential epoch), the acquireEnvironment/runTurn split with incremental finalizers, session-lifetime listeners demuxing into the current-turn sink, the dispatch wrapper, and the shared idempotent destroy path that the shutdown handler drains. Flag off means byte-identical behavior. Size 350 to 500 lines. Risk medium (the teardown-deferral refactor and the turn demux). -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 on the cold path (see the scope table in Q7), asserted by tests. Size 200 to 300 lines. Highest correctness value; highest correctness risk. -3. **Daytona.** Remove the `isDaytona` gate after slices 1 and 2 have run in real use with no problems. Verify cookie-fetch session reuse and mounted cwd survival across a park. Small code, real operational risk (billed idle time, remote liveness). +**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). -- `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` (default 600000). +- `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). -Add these to the runner's config surface, not read ad hoc from `process.env` scattered across files. Requests without a `session_id` never park. +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) -Carried from architecture-notes.md; unchanged here: - | 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 new turn | +| 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 | -| Multi-replica miss (future) | pool miss | cold replay; later route via `owner:session` affinity | +| 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 spike E5 (two prompts, one session) to confirm native memory and clean event demux across turns. -Before slice 2: run spike E6 (hold a permission request, then respond) to confirm the parked prompt continues with 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. - -Fingerprint contract (pinned after review): the history fingerprint is computed over the message array the server actually receives, which is the frontend's PRUNED array (the FE drops assistant turns that produced no answer part before sending). Unit tests pin this: a conversation containing an answer-less assistant turn must produce the same fingerprint as the pruned conversation the FE would send, and a fingerprint computed over the unpruned array must NOT match. If the FE pruning rule ever changes, these tests are the tripwire; the failure mode is a silent fall to cold replay, never a wrong continuation. +- 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 index abcb1f8cd7..733112aec1 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -4,11 +4,47 @@ Source of truth for progress. Keep this current. ## Current state (2026-07-08) -- Phase: implementation. Mahmoud approved the plan subject to the Codex xhigh review findings; all seven findings are folded into plan.md and architecture-notes.md (see the review changelog at the top of plan.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. One fold only, per the agreed loop bound. +- 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. + +## 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.** The design recommendation is now `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS=300000` (was 600000 in the slice-2 code). Whoever finalizes slice 2 should lower the code default to match. +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. + ## 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). From da32508b8e6d8c5651b4f32dd337e562a25345cf Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 15:06:50 +0200 Subject: [PATCH 06/13] docs(design): address second review round on session keep-alive Rewrites architecture-notes Decisions 1-3, 5, 6, 9, 10 for clarity, context, and trade-offs (project-scope home, in-place reconfig, warm/cold observability, stop-button and supersede, the backend-lock question, the listener demux, the relay-timeout and gate-parkability facts, the Daytona default-sandbox spec). Adds two follow-up designs: followups/parkable-gates (make the Pi and client-tool gates parkable) and followups/in-place-reconfiguration (change a live session's config without a respawn). Updates README and status. Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../projects/session-keepalive/README.md | 1 + .../session-keepalive/architecture-notes.md | 55 ++- .../in-place-reconfiguration/README.md | 19 ++ .../in-place-reconfiguration/design.md | 185 ++++++++++ .../followups/parkable-gates/README.md | 30 ++ .../followups/parkable-gates/design.md | 317 ++++++++++++++++++ .../projects/session-keepalive/status.md | 18 + 7 files changed, 616 insertions(+), 9 deletions(-) create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/README.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/in-place-reconfiguration/design.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/design.md diff --git a/docs/design/agent-workflows/projects/session-keepalive/README.md b/docs/design/agent-workflows/projects/session-keepalive/README.md index 66452b054c..c4e0c929cf 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/README.md +++ b/docs/design/agent-workflows/projects/session-keepalive/README.md @@ -9,6 +9,7 @@ Keep the harness session alive for a TTL after a turn ends, so the next message - [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 diff --git a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md index 8f41721614..d07acd9fef 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -149,6 +149,15 @@ Each decision below states the problem, the options, the trade-offs, the choice, **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. @@ -157,10 +166,20 @@ Each decision below states the problem, the options, the trade-offs, the choice, **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. Options here were: (a) mutate the live session to the new config, (b) continue with the old config and ignore the change, or (c) evict and cold-start with the new config. We chose **(c)**. A running harness cannot be reconfigured in place (its model, prompt, and tools were baked into the process at `createSession`), so (a) is not possible without tearing the process down anyway. Option (b) would silently ignore the user's edit, which is wrong. Option (c) evicts the parked session, destroys it, and cold-starts a fresh one with the new config. The user gets exactly what they configured, at the cost of one cold turn. A config change is a cold turn, by design. +**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. @@ -170,7 +189,16 @@ Each decision below states the problem, the options, the trade-offs, the choice, - **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. -**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). Options were: (a) reject the second turn with an error, or (b) supersede: destroy the busy session and cold-start the second turn fresh. We chose **(b), supersede**. It is simpler and it honors the rule "never fail a turn." The second turn runs; it just runs cold instead of continuing. The first turn's result is abandoned, which is the correct outcome when the user has moved on. 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: if a request arrives for a session already marked busy, the runner supersedes. +**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 not a real lock, like the rest of the backend uses.** The backend has a locking facility (for example around sessions) that could serialize turns on a conversation. We considered using it. We chose not to, for two reasons. First, keep-alive is single-replica and single-threaded, so the only race is inside one process, and a `busy` flag settles it without any distributed machinery. Second, the desired behavior on a race is not "block the second turn until the first finishes"; it is "let the second turn win, cold." A lock would make the second caller wait for a turn whose result they no longer want. The long-term picture is different: once the runner is multi-replica, or once turns can be parked durably on the interactions plane (Decision covered in Part 4), a real cross-process lock or an affinity route becomes the right tool. For this single-replica, in-memory feature, the flag is the honest fit. @@ -192,22 +220,31 @@ Each decision below states the problem, the options, the trade-offs, the choice, **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 -This is the decision Mahmoud asked to be rewritten to cover both harnesses. The full context is the four-gate table in Part 1. Here is what keep-alive does with each gate, why, and what it would take to extend it. +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 60-second deadline (`RELAY_TIMEOUT_MS`). 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. +- **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 Pi parkable (the future path, not v1).** The Pi gates are unparkable because the wait lives inside the sandbox on a bounded file poll, not in the runner as an answerable promise. Two things would have to change. First, the in-sandbox wait would need to be unbounded or resumable, instead of a 60-second fail-closed deadline; a park can outlast 60 seconds, so the relay would have to hold (or re-establish) the pending state rather than time out and error. Second, the pending decision would have to move into a runner-held handle, the way the Claude ACP request already is, so the runner has something to answer after the turn ends. That is a relay redesign, not a keep-alive change, so it is out of scope for v1 and recorded as future work. The honest v1 statement is: keep-alive makes the Claude approval path reliable now, and leaves a clear, scoped path to do the same for Pi later. Until then, the consequence for a Pi user is spelled out plainly: their approvals keep working through the cold decision-map replay, with the same drift risk that path has always had. +**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 (Claude path).** 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. +**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. @@ -249,7 +286,7 @@ Honest answer on the pool: it is **neutral to slightly helpful on the graceful p ## Decision 9: memory and CPU cost, measured, and how to size the pool -Mahmoud asked for real numbers, not guesses. These were measured on the Hetzner dev box, inside the runner sidecar, against genuinely parked Claude sessions (method and raw figures in status.md). +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):** @@ -274,9 +311,9 @@ The harness process dominates. Pss (which counts shared library and binary pages **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. A Daytona sandbox has a 15-minute idle auto-stop and ephemeral auto-delete (`provider.ts`) as a leak backstop. 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. +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.** 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 is 1 vCPU, 1 GiB RAM, 3 GiB disk, which runs at about $0.067 per hour. Two facts drive the design: +**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. 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..358f9f239a --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md @@ -0,0 +1,30 @@ +# Parkable gates: making Pi and client-tool approvals survive a turn + +Keep-alive slice 2 makes one approval gate survive a turn boundary: the Claude ACP +permission gate. When it fires, the runner still holds something it can answer 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 +property, so they stay on the slow cold-replay path. + +This folder is the design for giving those three gates the same property. + +## Who should read this + +- Anyone extending keep-alive past slice 2. +- 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). + +## Files + +- [design.md](design.md): the full design. The parkability property, each of the three + gates (how it pauses today, why it is not parkable, the options, the choice), how the + result composes with keep-alive and the interactions plane, scope and ordering, and risks. 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..93714eaa8c --- /dev/null +++ b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/design.md @@ -0,0 +1,317 @@ +# Parkable gates: how the other three approval gates pause, why they die on a turn boundary, and how to make them survive + +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. + +That property has a name in this design: a gate is **parkable**. 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. Only the Claude ACP permission gate has that handle today. The Pi custom-tool relay +gate, the Pi builtin gate, and the client-tool MCP pause do not, so each still destroys its +session on pause and resumes through cold replay (a fresh agent reads a flattened transcript, +re-issues the call from text, and hopes its arguments match a stored decision). That is the +path both production approval failures came from. + +This document explains, for each of those three gates, how it pauses today (with the real code +mechanism), why it cannot be parked as built, the options for making it parkable, the +trade-offs, and the recommended option. It closes with how the result composes with keep-alive +and the interactions plane, the scope and ordering, and the honest risks. + +Everything below is verified against `services/runner/src` as of 2026-07-08. + +--- + +## The parkability property, and where each gate holds its pending state today + +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, so the gate is parkable. If it lives +inside the sandbox, or the runner destroyed it on pause, there is nothing to answer, so the gate +is not parkable. + +| 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 runner records a durable +pending interaction and has no verdict yet, so it writes nothing; the in-sandbox poll spins to +its deadline and throws, the harness turns the throw into a tool error, and the turn ends. + +This whole wait runs in the Pi process, inside the sandbox. It is process memory. When the turn +ends and the session is torn down, the poll is gone. + +### Why it is not parkable + +The runner holds no handle. By the time the turn ends, the Pi side is either still spinning on a +file that will never appear or has already thrown. There is no promise in the runner's memory to +answer later, the way there is for a Claude ACP request. The decision to pause is expressed by +*the absence of a response file*, which is not a handle anyone can hold. + +### Options + +**Option A: raise or remove `RELAY_TIMEOUT_MS`.** Give the in-sandbox poll a much longer, or +unbounded, deadline so it does not throw while a human deliberates. + +- Trade-off: this changes nothing about parkability. The poll is still process memory inside the + sandbox. Today the session is destroyed at turn end, so a longer poll dies with it just the + same. Worse, an unbounded poll with no other change would block Pi's `prompt()` forever if the + answer never comes, which is the run-hang failure the approval model already learned to avoid. + A bigger number moves no handle into the runner. Rejected. + +**Option B: invert the relay so the runner owns the pending decision and the sandbox wait is +held open across a park.** Two things change together. First, the runner stops treating "no +response file" as a timeout to wait out. When `startToolRelay` sees an `ask` request, it records +a **runner-held pending handle** keyed by the tool-call id (the relay's analog of +`pendingPermissionRequests`) and does not write a response. Second, the in-sandbox poll becomes +park-aware: instead of a fixed 60-second deadline, it waits without timing out **while +keep-alive holds the Pi session alive**, and the runner writes the response file into that same +still-living sandbox when the human answers. 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. + +- Trade-off: this is a relay change, not a one-line config change. The poll must gain a + park-aware, keep-alive-gated wait (unbounded only while the session is parkable, still bounded + and fail-closed otherwise, so a non-keep-alive run keeps today's safety). The runner must emit + a `paused` stop for the egress stream while the Pi `prompt()` is genuinely still in flight + inside the blocked `execute`; the Claude path ends a turn while holding an out-of-band request, + whereas here the turn ends while an in-band tool call is suspended. That ordering is the novel + part and the main risk. In return, the Pi relay gate becomes as parkable as the Claude gate, + on the transport that already exists. + +**Option C: give Pi a first-class permission plane, the way Claude has one.** Stop expressing Pi +approvals through the relay files at all. Pi's ACP bridge would raise a real permission request +that the runner holds as a promise, exactly like Claude's ACP gate, and Gate 2 answers it with +no relay involved. + +- Trade-off: this is the cleanest end state. It removes the whole "pause expressed as a missing + file" awkwardness and makes the two harnesses share one gate mechanism. But Pi's bridge reports + `permissions: false` today (`how-approvals-work.md`, Pi's row); it has no answerable permission + plane, and adding one is upstream work in Pi, not something the runner can build alone. It is + the right north star and the wrong thing to depend on now. + +### Recommended option + +**Option B, with Option C as the stated north star.** Option B reaches the same parkability the +Claude gate has, using the relay files already in place, and it degrades cleanly: with keep-alive +off, or on any run that cannot park, the poll keeps its bounded fail-closed deadline and the gate +stays exactly as correct as today. It is the "relay restructure" this project is willing to do. +Option C is where the design should end up if Pi's bridge ever grows a native permission plane; +until then, B is what ships. + +Before/after, one Pi approval: + +- Today: Pi calls a gated tool. The runner records a durable interaction and writes no response. + The in-sandbox poll spins for 60 seconds and throws. The turn ends. The human clicks Approve. + A fresh Pi session cold-replays the transcript, re-issues the call from text, and the runner + matches it against the stored decision by name plus canonical arguments. If the regenerated + arguments drift, the gate re-fires. +- With Option B (inside the approval TTL): Pi calls a gated tool. The runner records a + runner-held handle and emits `paused`; keep-alive parks the live Pi session, and the blocked + `execute` callback keeps waiting. The human clicks Approve. The runner writes the response file + into the parked sandbox. The same blocked callback reads it and returns. The original call runs + with its original arguments. Nothing re-issues, so nothing drifts. + +## 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 synchronously +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. + +### Why it is not parkable + +Same reason as Gate 1, one level in. The wait is a synchronous block inside the Pi process, on +the same in-sandbox file poll. The runner holds nothing. A synchronous in-process block cannot by +itself survive a turn boundary. + +### Options and recommendation + +The mechanism is the relay, so the options are Gate 1's options. Recommended: **Option B**, the +same inverted, park-aware relay, shared with the custom-tool gate. One extra constraint applies +here. The hook's paused state must not be expressed as a `blockReason` (a deny), because +fail-closed treats a deny as final. Parking has to be a genuine third outcome (suspend and wait), +distinct from allow and deny, held open only while keep-alive holds the session. When keep-alive +cannot park, the hook keeps today's fail-closed timeout, so a builtin never runs unapproved. + +Because both Pi gates ride the same relay, they should be built as one change, not two. + +## 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. This is 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. 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. + +--- + +## How this composes with keep-alive 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 same two-tier model architecture-notes.md describes for the Claude gate. + +- **The parked handle is the fast, in-memory tier.** A runner-held relay handle (Pi) or a held + MCP socket (client tool) resumes the original call with no replay, valid for the approval TTL. +- **The durable interaction row is 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. That is the same settle-by-stored-decision mechanism + the cold path already uses. +- **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. This is identical to the Claude story in + architecture-notes.md "Relation to the interactions plane," now extended to the Pi and + client-tool gates. 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 +exactly today's cold path. Nothing here can fail a turn; the worst case is a cold restart. + +--- + +## Scope 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. + +- **v-next (the relay restructure).** Options B for both Pi gates, built as one change to the + relay: a runner-held pending handle, a park-aware keep-alive-gated wait replacing the fixed + 60-second deadline, and the response-into-parked-sandbox resume. This is the larger and + higher-value piece, because Pi has no human-in-the-loop today at all beyond the cold path, and + because a relay restructure is an accepted cost for this work. +- **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 north star).** Option C for Pi: a first-class Pi permission plane that makes the + relay files unnecessary, once Pi's bridge can raise an answerable permission request. Out of our + hands until Pi changes; recorded so the relay restructure is understood as a bridge to it, not a + final shape. + +--- + +## 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. Option B ends a turn while Pi's `prompt()` is still inside a + blocked `execute`. The egress must emit `paused` and stop streaming without Pi emitting an error + or a spurious result. This is the least-proven part and deserves a spike (drive one Pi session, + block a tool, park it a minute, answer it, and confirm the original call resumes) before the full + build, mirroring the slice-2 spike. +- **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. 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. +- **Credential epoch.** A parked Pi or client-tool handle inherits the same stale-credential risk + the Claude park has (architecture-notes.md Decision 7). The resumed call runs with the original + turn's baked credentials, so the same epoch check (expiry plus a process-local value hash) must + cover these handles too; a park that outlives its credentials evicts to cold. diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 733112aec1..910e25e5e1 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -18,6 +18,21 @@ Mahmoud reviewed PR #5153 and asked for a substantial rewrite for clarity, conte - `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). + ## Measured costs and mechanism research (2026-07-08) Recorded here as the source for the numbers now cited in the design docs. @@ -44,6 +59,9 @@ Recorded here as the source for the numbers now cited in the design docs. 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`.** Today the pool key's project scope comes from the mount-sign response because no project id rides the `/run` wire. The clean home is `runContext`, which the service already computes server-side from request state (the same server-verified path that produces the workflow id). A small wire addition (DTO, the two mirrors, goldens, one service line), to land when keep-alive stops being strictly runner-only. 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 From 36750b916c3da11efa42cd9e713165aa12ee44dc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 8 Jul 2026 15:09:29 +0200 Subject: [PATCH 07/13] docs(design): rewrite the why-not-a-backend-lock analysis The distributed Redis alive-lock already exists and the runner cooperates with it over the heartbeat; keep-alive's busy flag is not a reimplementation, it guards an in-process pool of live warmed sandbox handles a Redis key cannot represent. Addresses the second-review lock question. Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv --- .../projects/session-keepalive/architecture-notes.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md index d07acd9fef..5d840e8d75 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md +++ b/docs/design/agent-workflows/projects/session-keepalive/architecture-notes.md @@ -200,7 +200,13 @@ Option (c) is what the reviewer's expectation describes, and it is the better en **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 not a real lock, like the rest of the backend uses.** The backend has a locking facility (for example around sessions) that could serialize turns on a conversation. We considered using it. We chose not to, for two reasons. First, keep-alive is single-replica and single-threaded, so the only race is inside one process, and a `busy` flag settles it without any distributed machinery. Second, the desired behavior on a race is not "block the second turn until the first finishes"; it is "let the second turn win, cold." A lock would make the second caller wait for a turn whose result they no longer want. The long-term picture is different: once the runner is multi-replica, or once turns can be parked durably on the interactions plane (Decision covered in Part 4), a real cross-process lock or an affinity route becomes the right tool. For this single-replica, in-memory feature, the flag is the honest fit. +**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 From e17e1d019057ba1b1966889b9618644ebfe5dea3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 9 Jul 2026 18:56:02 +0200 Subject: [PATCH 08/13] docs(design): judge parkable gates by the call-sequence invariant, fold in the resume experiments Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../experiments/protocol.md | 119 ++++++++++ .../experiments/report.md | 190 ++++++++++++++++ .../followups/parkable-gates/README.md | 35 ++- .../followups/parkable-gates/design.md | 204 +++++++++++++----- .../session-keepalive/open-questions.md | 2 + .../projects/session-keepalive/status.md | 26 ++- 6 files changed, 509 insertions(+), 67 deletions(-) create mode 100644 docs/design/agent-workflows/projects/harness-session-resume/experiments/protocol.md create mode 100644 docs/design/agent-workflows/projects/harness-session-resume/experiments/report.md 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/session-keepalive/followups/parkable-gates/README.md b/docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/README.md index 358f9f239a..35b199901c 100644 --- 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 @@ -1,16 +1,28 @@ # Parkable gates: making Pi and client-tool approvals survive a turn -Keep-alive slice 2 makes one approval gate survive a turn boundary: the Claude ACP -permission gate. When it fires, the runner still holds something it can answer 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 -property, so they stay on the slow cold-replay path. - -This folder is the design for giving those three gates the same property. +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. ## 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`). @@ -22,9 +34,12 @@ This folder is the design for giving those three gates the same property. 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 parkability property, each of the three - gates (how it pauses today, why it is not parkable, the options, the choice), how the - result composes with keep-alive and the interactions plane, scope and ordering, and risks. +- [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), how + the result composes with keep-alive, session resume, and the interactions plane, ownership + and ordering, and risks. 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 index 93714eaa8c..1a62b669eb 100644 --- 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 @@ -1,32 +1,82 @@ -# Parkable gates: how the other three approval gates pause, why they die on a turn boundary, and how to make them survive +# 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. -That property has a name in this design: a gate is **parkable**. 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. Only the Claude ACP permission gate has that handle today. The Pi custom-tool relay -gate, the Pi builtin gate, and the client-tool MCP pause do not, so each still destroys its -session on pause and resumes through cold replay (a fresh agent reads a flattened transcript, -re-issues the call from text, and hopes its arguments match a stored decision). That is the -path both production approval failures came from. +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. -This document explains, for each of those three gates, how it pauses today (with the real code -mechanism), why it cannot be parked as built, the options for making it parkable, the -trade-offs, and the recommended option. It closes with how the result composes with keep-alive -and the interactions plane, the scope and ordering, and the honest risks. +Everything below is verified against `services/runner/src` as of 2026-07-08, and against the +kill-and-resume experiments of 2026-07-09 +([protocol](../../../harness-session-resume/experiments/protocol.md), +[report](../../../harness-session-resume/experiments/report.md)). -Everything below is verified against `services/runner/src` as of 2026-07-08. +--- + +## 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 a relay restructure, 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 -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, so the gate is parkable. If it lives -inside the sandbox, or the runner destroyed it on pause, there is nothing to answer, so the gate -is not parkable. +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? | |---|---|---|---| @@ -130,18 +180,20 @@ stays exactly as correct as today. It is the "relay restructure" this project is Option C is where the design should end up if Pi's bridge ever grows a native permission plane; until then, B is what ships. -Before/after, one Pi approval: +Before/after, one Pi approval, against the invariant: -- Today: Pi calls a gated tool. The runner records a durable interaction and writes no response. - The in-sandbox poll spins for 60 seconds and throws. The turn ends. The human clicks Approve. - A fresh Pi session cold-replays the transcript, re-issues the call from text, and the runner - matches it against the stored decision by name plus canonical arguments. If the regenerated - arguments drift, the gate re-fires. -- With Option B (inside the approval TTL): Pi calls a gated tool. The runner records a +- Today (tier 3): Pi calls a gated tool. The runner records a durable interaction and writes no + response. The in-sandbox poll spins for 60 seconds and throws. The turn ends. 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 B (tier 1, inside the approval TTL): Pi calls a gated tool. The runner records a runner-held handle and emits `paused`; keep-alive parks the live Pi session, and the blocked `execute` callback keeps waiting. The human clicks Approve. The runner writes the response file into the parked sandbox. The same blocked callback reads it and returns. The original call runs - with its original arguments. Nothing re-issues, so nothing drifts. + 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 @@ -173,6 +225,12 @@ cannot park, the hook keeps today's fail-closed timeout, so a builtin never runs Because both Pi gates ride the same relay, 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 @@ -219,8 +277,8 @@ JSON-RPC result into that same held socket, and Claude continues. 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. This is the load-bearing - open question for this gate. + 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 @@ -228,8 +286,10 @@ the harness rather than replaying the transcript and letting Claude re-issue the - 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. 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. + 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 @@ -244,41 +304,70 @@ rather than promise a park the transport cannot hold. --- -## How this composes with keep-alive and the interactions plane +## 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 same two-tier model architecture-notes.md describes for the Claude gate. - -- **The parked handle is the fast, in-memory tier.** A runner-held relay handle (Pi) or a held - MCP socket (client tool) resumes the original call with no replay, valid for the approval TTL. -- **The durable interaction row is the slow tier.** The runner already writes a +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 runner-held relay handle (Pi) 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. That is the same settle-by-stored-decision mechanism - the cold path already uses. + 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. This is identical to the Claude story in - architecture-notes.md "Relation to the interactions plane," now extended to the Pi and - client-tool gates. This work does not build the cross-plane resolver; it makes the fast lane - real for three more gates and leaves the row untouched. + 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 -exactly today's cold path. Nothing here can fail a turn; the worst case is a cold restart. - ---- - -## Scope and ordering +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. +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 relay inversion 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 relay restructure).** Options B for both Pi gates, built as one change to the relay: a runner-held pending handle, a park-aware keep-alive-gated wait replacing the fixed 60-second deadline, and the response-into-parked-sandbox resume. This is the larger and - higher-value piece, because Pi has no human-in-the-loop today at all beyond the cold path, and - because a relay restructure is an accepted cost for this work. + higher-value piece, because Pi has no tier-1 path today at all, and because a relay + restructure is an accepted cost for this work. Coordinate with the backend warm-session move; + the pending-handle registry 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 @@ -297,9 +386,13 @@ not part of shipping keep-alive. blocked `execute`. The egress must emit `paused` and stop streaming without Pi emitting an error or a spurious result. This is the least-proven part and deserves a spike (drive one Pi session, block a tool, park it a minute, answer it, and confirm the original call resumes) before the full - build, mirroring the slice-2 spike. + build, mirroring the slice-2 spike. The experiments narrowed the blast radius of getting it + wrong: Pi's session file already holds the pending call mid-block, so a park that dies degrades + to a tier-2 continuation, not to a lost turn. They did not test the park itself; the spike + still must. - **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. Measure it before committing + 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 @@ -311,7 +404,8 @@ not part of shipping keep-alive. 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. -- **Credential epoch.** A parked Pi or client-tool handle inherits the same stale-credential risk - the Claude park has (architecture-notes.md Decision 7). The resumed call runs with the original - turn's baked credentials, so the same epoch check (expiry plus a process-local value hash) must - cover these handles too; a park that outlives its credentials evicts to cold. +- **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/open-questions.md b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md index fbf89fa55d..43f48afced 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/open-questions.md +++ b/docs/design/agent-workflows/projects/session-keepalive/open-questions.md @@ -8,6 +8,8 @@ None of these block slice 1. They refine defaults and edge behavior. Each states 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. diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 910e25e5e1..ce1c1e0742 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -33,6 +33,28 @@ Mahmoud did a second pass on PR #5153 (13 inline comments on architecture-notes. - **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. + ## Measured costs and mechanism research (2026-07-08) Recorded here as the source for the numbers now cited in the design docs. @@ -55,11 +77,11 @@ Recorded here as the source for the numbers now cited in the design docs. ## Follow-ups recorded (out of scope for this design) -1. **Lower the implemented approval-TTL default to 5 minutes.** The design recommendation is now `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS=300000` (was 600000 in the slice-2 code). Whoever finalizes slice 2 should lower the code default to match. +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`.** Today the pool key's project scope comes from the mount-sign response because no project id rides the `/run` wire. The clean home is `runContext`, which the service already computes server-side from request state (the same server-verified path that produces the workflow id). A small wire addition (DTO, the two mirrors, goldens, one service line), to land when keep-alive stops being strictly runner-only. Decouples the pool key from the mount signer (Decision 1). +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). From b271dea44aa74a17b8407e730b9be309d58f6c36 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 9 Jul 2026 19:27:53 +0200 Subject: [PATCH 09/13] docs(design): address review round on parkable gates (destroy-first pause, pi-acp permission plane, per-gate cold paths) Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../followups/parkable-gates/design.md | 335 ++++++++++++------ .../projects/session-keepalive/status.md | 27 ++ 2 files changed, 260 insertions(+), 102 deletions(-) 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 index 1a62b669eb..4ae7c30978 100644 --- 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 @@ -65,7 +65,7 @@ So the tiers rank like this, and only the first is exact: 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 a relay restructure, and it makes the durable-decision +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. --- @@ -115,78 +115,157 @@ throw new Error("tool relay timed out for ") // dispatch.ts:11 `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 runner records a durable -pending interaction and has no verdict yet, so it writes nothing; the in-sandbox poll spins to -its deadline and throws, the harness turns the throw into a tool error, and the turn ends. - -This whole wait runs in the Pi process, inside the sandbox. It is process memory. When the turn -ends and the session is torn down, the poll is gone. - -### Why it is not parkable - -The runner holds no handle. By the time the turn ends, the Pi side is either still spinning on a -file that will never appear or has already thrown. There is no promise in the runner's memory to -answer later, the way there is for a Claude ACP request. The decision to pause is expressed by -*the absence of a response file*, which is not a handle anyone can hold. +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`.** Give the in-sandbox poll a much longer, or -unbounded, deadline so it does not throw while a human deliberates. - -- Trade-off: this changes nothing about parkability. The poll is still process memory inside the - sandbox. Today the session is destroyed at turn end, so a longer poll dies with it just the - same. Worse, an unbounded poll with no other change would block Pi's `prompt()` forever if the - answer never comes, which is the run-hang failure the approval model already learned to avoid. - A bigger number moves no handle into the runner. Rejected. - -**Option B: invert the relay so the runner owns the pending decision and the sandbox wait is -held open across a park.** Two things change together. First, the runner stops treating "no -response file" as a timeout to wait out. When `startToolRelay` sees an `ask` request, it records -a **runner-held pending handle** keyed by the tool-call id (the relay's analog of -`pendingPermissionRequests`) and does not write a response. Second, the in-sandbox poll becomes -park-aware: instead of a fixed 60-second deadline, it waits without timing out **while -keep-alive holds the Pi session alive**, and the runner writes the response file into that same -still-living sandbox when the human answers. 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. - -- Trade-off: this is a relay change, not a one-line config change. The poll must gain a - park-aware, keep-alive-gated wait (unbounded only while the session is parkable, still bounded - and fail-closed otherwise, so a non-keep-alive run keeps today's safety). The runner must emit - a `paused` stop for the egress stream while the Pi `prompt()` is genuinely still in flight - inside the blocked `execute`; the Claude path ends a turn while holding an out-of-band request, - whereas here the turn ends while an in-band tool call is suspended. That ordering is the novel - part and the main risk. In return, the Pi relay gate becomes as parkable as the Claude gate, - on the transport that already exists. - -**Option C: give Pi a first-class permission plane, the way Claude has one.** Stop expressing Pi -approvals through the relay files at all. Pi's ACP bridge would raise a real permission request -that the runner holds as a promise, exactly like Claude's ACP gate, and Gate 2 answers it with -no relay involved. - -- Trade-off: this is the cleanest end state. It removes the whole "pause expressed as a missing - file" awkwardness and makes the two harnesses share one gate mechanism. But Pi's bridge reports - `permissions: false` today (`how-approvals-work.md`, Pi's row); it has no answerable permission - plane, and adding one is upstream work in Pi, not something the runner can build alone. It is - the right north star and the wrong thing to depend on now. +**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: 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. + +An earlier draft dismissed this as upstream work inside Pi. That was wrong on both counts, and +the corrected picture makes C much closer than "north star": + +- **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 is actually missing, verified against the bundled packages: + +- **Payload fidelity.** `pi-acp` synthesizes the ACP tool call from the dialog: id + `pi-ui-`, `rawInput` limited to title/message/options (`index.js:565, 1130-1144`). The + real tool-call id and arguments do not ride the request natively, and the approval card and + decision map key on them. Either encode them in the confirm message and parse runner-side, + or make a small `pi-acp` change forwarding structured metadata. +- **Ownership.** Pi (`@earendil-works/pi-coding-agent`) is Mario Zechner's; `pi-acp` is a + separate MIT adapter by Sergii Kozak (`svkozak/pi-acp`, v0.0.29 bundled). Neither is ours. + A small upstream PR is the clean route, and this repo already `pnpm patch`es + `sandbox-agent`, so a patch is the normal escape hatch, not a blocker. +- **An unproven hop.** Nobody has driven `ctx.ui.confirm` through the sandbox-agent daemon to + the runner's permission listener. The Daytona trust prompt rides the same dialog channel, so + the path exists, but the gate use needs a spike. + +- 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 and needs the payload-fidelity gap closed by encoding or by an + upstream change. It shares Option B's novel ordering (the turn still ends while Pi's + `prompt()` hangs inside a blocked hook), so that risk is common to both. ### Recommended option -**Option B, with Option C as the stated north star.** Option B reaches the same parkability the -Claude gate has, using the relay files already in place, and it degrades cleanly: with keep-alive -off, or on any run that cannot park, the poll keeps its bounded fail-closed deadline and the gate -stays exactly as correct as today. It is the "relay restructure" this project is willing to do. -Option C is where the design should end up if Pi's bridge ever grows a native permission plane; -until then, B is what ships. - -Before/after, one Pi approval, against the invariant: - -- Today (tier 3): Pi calls a gated tool. The runner records a durable interaction and writes no - response. The in-sandbox poll spins for 60 seconds and throws. The turn ends. 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 +**Spike Option C's wire hop first; ship C if the payload survives, otherwise B.** The two +options reach the same parkability and share the same cold fall-back and the same novel +ordering risk. They differ in where the pending handle lives: C reuses the exact ACP request +the Claude park already holds (smallest delta from slice 2, one gate mechanism for both +harnesses, but a third-party bridge in the path and a payload gap to close); B keeps everything +in our own code on the existing relay files (no new dependency, but three code deltas and a new +resume verb). The C spike is small: one Pi session under the bridge, a `tool_call` hook that +awaits `ctx.ui.confirm`, and a check of what the runner's permission listener receives. If the +tool name and arguments survive that hop (natively, by encoding, or by a small accepted +upstream change), C is the better build; if not, B ships and C stays the end state. 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 (the warm mechanics shown are Option B's; +under C the file write is replaced by `respondPermission` resolving the held dialog): + +- 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 B (tier 1, inside the approval TTL): Pi calls a gated tool. The runner records a runner-held handle and emits `paused`; keep-alive parks the live Pi session, and the blocked @@ -200,30 +279,46 @@ Before/after, one Pi approval, against the invariant: ### 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 synchronously -on `relayPermissionCheck` (`dispatch.ts:131-213`). That function writes a permission request into +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. -### Why it is not parkable +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 -Same reason as Gate 1, one level in. The wait is a synchronous block inside the Pi process, on -the same in-sandbox file poll. The runner holds nothing. A synchronous in-process block cannot by -itself survive a turn boundary. +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. Recommended: **Option B**, the -same inverted, park-aware relay, shared with the custom-tool gate. One extra constraint applies -here. The hook's paused state must not be expressed as a `blockReason` (a deny), because -fail-closed treats a deny as final. Parking has to be a genuine third outcome (suspend and wait), -distinct from allow and deny, held open only while keep-alive holds the session. When keep-alive -cannot park, the hook keeps today's fail-closed timeout, so a builtin never runs unapproved. +The mechanism is the relay, so the options are Gate 1's options, and the same recommendation +applies: spike C's hop; C if the payload survives, else B. One extra constraint applies here +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. Under C the constraint disappears: the hook awaits +`ctx.ui.confirm` and the answer is the answer. + +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 relay, they should be built as one change, not two. +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 @@ -304,6 +399,35 @@ 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 @@ -311,9 +435,10 @@ that owns it was torn down. So this work sits on top of keep-alive slices 1 and 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 runner-held relay handle (Pi) or a - held MCP socket (client tool) resumes the original call with no replay, valid for the approval - TTL. The only byte-exact tier. +- **The parked handle is tier 1, the fast in-memory tier.** A parked Pi gate (a relay handle + under Option B, a held ACP permission request under 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, @@ -357,39 +482,45 @@ This is an incremental follow-up **after** keep-alive slices 1 and 2 have run in 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 relay inversion and the client-tool hold-open land on top of, or inside, that work, on its +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 relay restructure).** Options B for both Pi gates, built as one change to the - relay: a runner-held pending handle, a park-aware keep-alive-gated wait replacing the fixed - 60-second deadline, and the response-into-parked-sandbox resume. This is the larger and - higher-value piece, because Pi has no tier-1 path today at all, and because a relay - restructure is an accepted cost for this work. Coordinate with the backend warm-session move; - the pending-handle registry should live wherever the pool lands. +- **v-next (the Pi gate park).** Both Pi gates as one change, with the mechanism decided by the + small Option C spike: either the extension-UI permission plane (C: the gate awaits + `ctx.ui.confirm`, the bridge raises a real ACP permission request, and slice 2's park + machinery holds it) or the parked relay wait (B: park instead of destroy, the write-the-file + resume verb, a park-length poll deadline). 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 north star).** Option C for Pi: a first-class Pi permission plane that makes the - relay files unnecessary, once Pi's bridge can raise an answerable permission request. Out of our - hands until Pi changes; recorded so the relay restructure is understood as a bridge to it, not a - final shape. +- **later (the cleanup).** If C ships on encoded payloads, upstream the structured-metadata + change to `pi-acp` (or carry it as a pnpm patch) so the encoding disappears. If B ships, C + stays the recorded end state, now known to be reachable through the existing bridge rather + than blocked on Pi. --- ## 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. Option B ends a turn while Pi's `prompt()` is still inside a - blocked `execute`. The egress must emit `paused` and stop streaming without Pi emitting an error - or a spurious result. This is the least-proven part and deserves a spike (drive one Pi session, - block a tool, park it a minute, answer it, and confirm the original call resumes) before the full - build, mirroring the slice-2 spike. The experiments narrowed the blast radius of getting it - wrong: Pi's session file already holds the pending call mid-block, so a park that dies degrades - to a tier-2 continuation, not to a lost turn. They did not test the park itself; the spike - still must. + holding an out-of-band request. Options B and C alike end a turn while Pi's `prompt()` is still + inside a blocked hook or `execute`. The egress must emit `paused` and stop streaming without Pi + emitting an error or a spurious result. This is the least-proven part and deserves a spike + (drive one Pi session, block a tool, park it a minute, answer it, and confirm the original call + resumes) before the full build, mirroring the slice-2 spike. The experiments narrowed the blast + radius of getting it wrong: Pi's session file already holds the pending call mid-block, so a + park that dies degrades to a tier-2 continuation, not to a lost turn. They did not test the + park itself; the spike still must. +- **The Option C hop and its payload.** Nobody has driven `ctx.ui.confirm` from an in-sandbox Pi + extension through the sandbox-agent daemon to the runner's permission listener, and `pi-acp` + forwards only the dialog fields, not the gated call's id and arguments. The C spike must show + the hop works end to end and that the payload survives (by encoding or a small upstream + change); until it does, Option B is the default build. - **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 diff --git a/docs/design/agent-workflows/projects/session-keepalive/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index ce1c1e0742..85842da87e 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -55,6 +55,33 @@ invariant the same day, and its ownership section now states that the Pi relay i 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). + ## Measured costs and mechanism research (2026-07-08) Recorded here as the source for the numbers now cited in the design docs. From 5e6868a078866164bac09bb168609b242d9f5b2b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 9 Jul 2026 20:03:56 +0200 Subject: [PATCH 10/13] docs(design): promote parkable-gates Option C to proven after the live Pi spike Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../followups/parkable-gates/README.md | 14 +- .../followups/parkable-gates/design.md | 189 +++++++++------- .../spike-option-c/acp-client.mjs | 201 ++++++++++++++++++ .../evidence/allow-transcript.jsonl | 27 +++ .../evidence/deny-transcript.jsonl | 28 +++ .../evidence/drop-transcript.jsonl | 26 +++ .../evidence/hold-transcript.jsonl | 40 ++++ .../evidence/rejecterr-transcript.jsonl | 29 +++ .../parkable-gates/spike-option-c/protocol.md | 121 +++++++++++ .../parkable-gates/spike-option-c/report.md | 140 ++++++++++++ .../spike-option-c/spike-extension.js | 80 +++++++ .../projects/session-keepalive/status.md | 17 ++ 12 files changed, 830 insertions(+), 82 deletions(-) create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/acp-client.mjs create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/allow-transcript.jsonl create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/deny-transcript.jsonl create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/drop-transcript.jsonl create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/hold-transcript.jsonl create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/evidence/rejecterr-transcript.jsonl create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/protocol.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/report.md create mode 100644 docs/design/agent-workflows/projects/session-keepalive/followups/parkable-gates/spike-option-c/spike-extension.js 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 index 35b199901c..028b3d3258 100644 --- 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 @@ -18,6 +18,12 @@ harnesses, but neither harness will answer it on load; both settle it as errored 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. @@ -40,6 +46,8 @@ work (extending the warm park to the remaining gates) cannot be replaced by it. ## 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), how - the result composes with keep-alive, session resume, and the interactions plane, ownership - and ordering, and risks. + 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 index 4ae7c30978..9b46f63243 100644 --- 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 @@ -8,10 +8,11 @@ This document extends that property to the three gates that still lack it: the P 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, and against the +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)). +[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)). --- @@ -196,13 +197,14 @@ does the decision arrive at a genuinely new agent turn, the cold path below. 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: 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. +**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, and -the corrected picture makes C much closer than "north star": +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` @@ -221,45 +223,67 @@ the corrected picture makes C much closer than "north star": request, park the session, `respondPermission` on resume) would apply with a new gate type and almost no new mechanism. -What is actually missing, verified against the bundled packages: - -- **Payload fidelity.** `pi-acp` synthesizes the ACP tool call from the dialog: id - `pi-ui-`, `rawInput` limited to title/message/options (`index.js:565, 1130-1144`). The - real tool-call id and arguments do not ride the request natively, and the approval card and - decision map key on them. Either encode them in the confirm message and parse runner-side, - or make a small `pi-acp` change forwarding structured metadata. -- **Ownership.** Pi (`@earendil-works/pi-coding-agent`) is Mario Zechner's; `pi-acp` is a - separate MIT adapter by Sergii Kozak (`svkozak/pi-acp`, v0.0.29 bundled). Neither is ours. - A small upstream PR is the clean route, and this repo already `pnpm patch`es - `sandbox-agent`, so a patch is the normal escape hatch, not a blocker. -- **An unproven hop.** Nobody has driven `ctx.ui.confirm` through the sandbox-agent daemon to - the runner's permission listener. The Daytona trust prompt rides the same dialog channel, so - the path exists, but the gate use needs a spike. +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 and needs the payload-fidelity gap closed by encoding or by an - upstream change. It shares Option B's novel ordering (the turn still ends while Pi's - `prompt()` hangs inside a blocked hook), so that risk is common to both. + 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 -**Spike Option C's wire hop first; ship C if the payload survives, otherwise B.** The two -options reach the same parkability and share the same cold fall-back and the same novel -ordering risk. They differ in where the pending handle lives: C reuses the exact ACP request -the Claude park already holds (smallest delta from slice 2, one gate mechanism for both -harnesses, but a third-party bridge in the path and a payload gap to close); B keeps everything -in our own code on the existing relay files (no new dependency, but three code deltas and a new -resume verb). The C spike is small: one Pi session under the bridge, a `tool_call` hook that -awaits `ctx.ui.confirm`, and a check of what the runner's permission listener receives. If the -tool name and arguments survive that hop (natively, by encoding, or by a small accepted -upstream change), C is the better build; if not, B ships and C stays the end state. 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 (the warm mechanics shown are Option B's; -under C the file write is replaced by `respondPermission` resolving the held dialog): +**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 @@ -267,12 +291,13 @@ under C the file write is replaced by `respondPermission` resolving the held dia 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 B (tier 1, inside the approval TTL): Pi calls a gated tool. The runner records a - runner-held handle and emits `paused`; keep-alive parks the live Pi session, and the blocked - `execute` callback keeps waiting. The human clicks Approve. The runner writes the response file - into the parked sandbox. The same blocked callback reads it and returns. 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. +- 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 @@ -304,13 +329,15 @@ 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: spike C's hop; C if the payload survives, else B. One extra constraint applies here -under B. The paused state must become a genuine third outcome (suspend and keep polling), +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. Under C the constraint disappears: the hook awaits -`ctx.ui.confirm` and the answer is the answer. +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 @@ -435,10 +462,9 @@ that owns it was torn down. So this work sits on top of keep-alive slices 1 and 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 relay handle - under Option B, a held ACP permission request under 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. +- **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, @@ -487,40 +513,45 @@ schedule; building them against the current runner-local pool would produce a co 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).** Both Pi gates as one change, with the mechanism decided by the - small Option C spike: either the extension-UI permission plane (C: the gate awaits - `ctx.ui.confirm`, the bridge raises a real ACP permission request, and slice 2's park - machinery holds it) or the parked relay wait (B: park instead of destroy, the write-the-file - resume verb, a park-length poll deadline). 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 (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).** If C ships on encoded payloads, upstream the structured-metadata - change to `pi-acp` (or carry it as a pnpm patch) so the encoding disappears. If B ships, C - stays the recorded end state, now known to be reachable through the existing bridge rather - than blocked on Pi. +- **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. Options B and C alike end a turn while Pi's `prompt()` is still - inside a blocked hook or `execute`. The egress must emit `paused` and stop streaming without Pi - emitting an error or a spurious result. This is the least-proven part and deserves a spike - (drive one Pi session, block a tool, park it a minute, answer it, and confirm the original call - resumes) before the full build, mirroring the slice-2 spike. The experiments narrowed the blast - radius of getting it wrong: Pi's session file already holds the pending call mid-block, so a - park that dies degrades to a tier-2 continuation, not to a lost turn. They did not test the - park itself; the spike still must. -- **The Option C hop and its payload.** Nobody has driven `ctx.ui.confirm` from an in-sandbox Pi - extension through the sandbox-agent daemon to the runner's permission listener, and `pi-acp` - forwards only the dialog fields, not the gated call's id and arguments. The C spike must show - the hop works end to end and that the payload survives (by encoding or a small upstream - change); until it does, Option B is the default build. +- **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 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/status.md b/docs/design/agent-workflows/projects/session-keepalive/status.md index 85842da87e..40c96bca45 100644 --- a/docs/design/agent-workflows/projects/session-keepalive/status.md +++ b/docs/design/agent-workflows/projects/session-keepalive/status.md @@ -82,6 +82,23 @@ the answer comes after the park is gone") answering the how-does-cold-work comme 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. From 24469a3fc9c97e3ce292463e400f5e592a1c34a4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 9 Jul 2026 20:21:03 +0200 Subject: [PATCH 11/13] docs(design): plan-feature workspace for Pi approval parking (Option C) Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../projects/pi-approval-parking/README.md | 31 +++ .../projects/pi-approval-parking/context.md | 77 +++++++ .../pi-approval-parking/open-questions.md | 44 ++++ .../projects/pi-approval-parking/plan.md | 197 ++++++++++++++++++ .../projects/pi-approval-parking/research.md | 145 +++++++++++++ .../projects/pi-approval-parking/status.md | 40 ++++ 6 files changed, 534 insertions(+) create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/README.md create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/context.md create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/plan.md create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/research.md create mode 100644 docs/design/agent-workflows/projects/pi-approval-parking/status.md 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..64d975a743 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md @@ -0,0 +1,44 @@ +# Pi approval parking: open questions + +Each question states a working default so the plan can proceed. The first two gate slice +order; the rest refine details. + +1. **The live daemon confidence run (slice 0).** 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), but it has not been run live. + Working default: run slice 0 first; if the daemon leg drops or mangles the request, fall + back to the parkable-gates design's Option B and stop. + +2. **Approval-card rendering for the envelope identity.** The `interaction_request` payload + today carries the harness's `toolCall` object. For a Pi dialog gate that object is the + synthetic `pi-ui-` dialog call whose `rawInput` is `{method, title, message}`; naive + pass-through would render envelope JSON in the approval card. The plan synthesizes the + card payload from the envelope (real tool name via the existing `resolvedName` stamp, real + arguments as `rawInput`). Working default: synthesize; confirm with a frontend check in + slice 4 that the card renders the same as a relay-gate card does today. 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.** The plan writes a consumed + dialog-allow into the turn's stored-decisions structure so the relay watcher's + defense-in-depth check passes (plan.md, slice 2). The alternative was skipping relay + enforcement under the flag. Working default: write-through; confirm during slice 2 that + the stored-decisions object is reachable from the responder scope without a layering + violation (both are built in `runTurn`; research.md §3). + +4. **Flag coupling.** `AGENTA_RUNNER_PI_DIALOG_GATE` works without keep-alive but produces a + half-state (better gates, no parking). Working default: independent flags, documented + advice to enable DG only where KA is on. Alternative: make DG imply nothing without KA by + checking KA runner-side. Decide at slice 2 review. + +5. **Deny-warm semantics.** 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). That + differs from Claude's deny (reply `reject`, harness continues similarly). Confirm during + slice 4 that continuing live after a deny is the wanted UX, or whether a deny should also + end the turn. Working default: continue live; it matches what Pi does today when a deny + arrives within the poll window. + +6. **The `agenta-approval` title as pre-filter.** A user extension could theoretically raise + its own `confirm` titled `agenta-approval` with garbage. Parsing is strict (wrong kind or + version falls through to the fail-closed spec-less path), so the worst case is a paused + turn, not a wrong allow. Working default: accept; revisit if user-authored Pi extensions + ever ship. 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..a7bbed3310 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md @@ -0,0 +1,197 @@ +# 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 dialog `title` is fixed to +`agenta-approval` and acts as the cheap pre-filter; the envelope is the authority. Parsing is +strict: wrong `kind`, unknown `v`, or unparseable JSON means the request is NOT treated as a +Pi gate and falls through to today's spec-less handling, which pauses fail-closed. Never +guess. + +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. 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). +- `acp-interactions.ts`: in `handleRequest` (`:186`), before `buildGateDescriptor`, try + `parsePiGateEnvelope`. On a hit, build the `GateDescriptor` from the envelope and stamp the + emitted `toolCall` payload with the envelope identity (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. +- Reply mapping: `decisionToReply` (`responder.ts:485-493`) falls back to `once`/`reject`, + but the Pi dialog offers `yes`/`no`. Fix generically: choose by option `kind` + (`allow_once`/`reject_once`) from the request's options when the literal ids are absent. + One function, unit-tested against both harnesses' option shapes. +- Unit tests: envelope parse (valid, wrong kind, wrong version, hostile strings from the + spike probe), classification (envelope hit vs spec-less fallthrough), reply mapping. + +Deliverable: a Pi dialog gate flows through the responder with the right identity and gets an +instant allow or deny; an ask pauses exactly like today (no parking yet). Ship-safe alone. + +### 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. +- Double-gate handling: the relay watcher still runs `permissions.decide` on the execution + request (`relay.ts:240-264`). After a dialog allow, that second check must pass. Chosen + mechanism: when the responder answers a Pi dialog gate with allow (instant or resumed), it + records the decision into the turn's stored-decisions structure (the same object `decide` + consults), so the relay check consumes it (`stored.take`). This keeps the relay as + defense-in-depth with one source of truth, instead of disabling it. The alternative + (skip relay enforcement when the dialog plane is on) removes the second check entirely; + rejected because a bug in the extension flag plumbing would then leave zero gates. +- Flag: `AGENTA_RUNNER_PI_DIALOG_GATE` (default off), read runner-side in `run-plan.ts` and + exported into the sandbox env (the existing pattern, research.md §7). One flag controls + both sides coherently because the runner installs the extension per run (research.md §6). +- Tests: extension-level unit tests run through the existing extension test seams; a + dispatch-level test that a dialog-allowed custom tool executes exactly once through the + relay. + +Deliverable: with the flag on, both Pi gates ride the dialog plane end to end; asks still +pause-and-destroy (parking arrives in slice 3). Behavior with the flag off is byte-identical. + +### Slice 3: park and resume + +- `sandbox_agent.ts` `onUserApprovalGate` (`:1321-1338`): record `parkedApproval` for the Pi + dialog gate too, with `gateType: "pi-dialog-permission"` and the envelope identity. 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 resume's `respondPermission` needs the + slice-1 reply mapping (allow -> the `allow_once` option) and, for a custom-tool gate, the + stored-decision record from slice 2 so the follow-on relay execution passes. +- Multi-gate: Pi raises one dialog at a time (the hook blocks the loop), so + `approvalGateCount` stays 1 on this path; assert that in a test rather than assuming it. +- 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 custom tools | tool registration; `runResolvedTool` relay EXECUTION (results still flow over the relay files); the old permission path behind the flag for rollback | the dialog gate (`ctx.ui.confirm` + envelope) at both gates | +| Relay (`relay.ts`, `dispatch.ts`) | nothing yet (`relayPermissionCheck` and `handlePermissionRelayRequest` become dead when the flag is on; delete after a bake period, recorded follow-up) | the watcher, execution dispatch, `permissions.decide` defense-in-depth | none | +| Responder seam (`acp-interactions.ts`) | nothing | all pause/park/reply mechanics | envelope detection + `GateDescriptor` from envelope + card payload synthesis | +| Reply mapping (`responder.ts`) | nothing | `once`/`reject` literals for Claude | kind-based option selection (`allow_once`/`reject_once`) | +| Park record (`sandbox_agent.ts`) | nothing | everything | `gateType: "pi-dialog-permission"` + envelope identity in `parkedApproval` | +| Stored decisions | nothing | keying (name + canonical args) | responder writes a consumed dialog-allow into the turn's stored decisions (the double-gate bridge) | +| Config | nothing | `AGENTA_RUNNER_SESSION_KEEPALIVE`, TTLs, pool cap | `AGENTA_RUNNER_PI_DIALOG_GATE` (default off; flip default after slice 4 greens) | +| 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. Flag names: KA = `AGENTA_RUNNER_SESSION_KEEPALIVE`, DG = `AGENTA_RUNNER_PI_DIALOG_GATE`. + +| Scenario | Behavior | +|---|---| +| Warm approve (KA+DG 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 on, DG off | Exactly today: relay-poll gates, pause destroys the session, cold decision-map resume. | +| KA off, DG on | The dialog gate still works (instant allow/deny from the responder; better card identity), but an ask pauses and destroys the session (no pool), and the dialog dies with it, fail-closed. Cold resume as today. Acceptable, but flip DG on only where KA is on to avoid a confusing half-state; state this in the rollout note. | +| Both off | Byte-identical to today. | +| 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. +- **Flag order.** Ship slices 1-3 dark, then enable `AGENTA_RUNNER_PI_DIALOG_GATE` on the dev + stack with keep-alive already on, run slice 4, then default it on. The old relay permission + path stays in the code one release as the rollback lever, then gets deleted (follow-up). +- **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. Delete `relayPermissionCheck` / `handlePermissionRelayRequest` after the bake period. +3. 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 spec-less), reply-option mapping (Claude ids, Pi kinds), stored + decision write-through on dialog-allow. +- Dispatch (fake session): Pi ask parks; resume-approve runs original call once; resume-deny + blocks; TTL expiry -> cold; approval-mismatch evicts; `approvalGateCount` stays 1; + double-gate (dialog allow then relay execution) executes exactly once. +- 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..d82e19b6cc --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/research.md @@ -0,0 +1,145 @@ +# 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. + +## 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.** `decisionToReply` (`src/responder.ts:485-493`) hard-falls-back to + `"once"`/`"reject"`. This matters below: the Pi dialog's option ids are `"yes"`/`"no"`. + +## 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`), not Claude's `once`/`reject`. + +## 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. +- Runner env flags are read in `run-plan.ts` and passed into the sandbox env; that is the + existing pattern for the new transport flag. 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..b50c92ade5 --- /dev/null +++ b/docs/design/agent-workflows/projects/pi-approval-parking/status.md @@ -0,0 +1,40 @@ +# 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). + +## Next steps + +1. Slice 0: the live daemon confidence run (reuse the committed spike assets). +2. Coordination check with JP on where the pool/park machinery lives before slice 3. +3. Slices 1-4 per plan.md. From 4e9a2c5d80c60bf1a0f50cab337c3f941a115a2f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 9 Jul 2026 20:52:01 +0200 Subject: [PATCH 12/13] docs(design): fold the plan review into pi-approval-parking (3 blockers, 4 must-fixes) Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../pi-approval-parking/open-questions.md | 76 ++++---- .../projects/pi-approval-parking/plan.md | 179 +++++++++++++----- .../projects/pi-approval-parking/research.md | 29 ++- .../projects/pi-approval-parking/status.md | 47 ++++- 4 files changed, 240 insertions(+), 91 deletions(-) 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 index 64d975a743..01b53a50b2 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md @@ -1,44 +1,50 @@ # Pi approval parking: open questions -Each question states a working default so the plan can proceed. The first two gate slice -order; the rest refine details. +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).** 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), but it has not been run live. - Working default: run slice 0 first; if the daemon leg drops or mangles the request, fall +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.** The `interaction_request` payload - today carries the harness's `toolCall` object. For a Pi dialog gate that object is the - synthetic `pi-ui-` dialog call whose `rawInput` is `{method, title, message}`; naive - pass-through would render envelope JSON in the approval card. The plan synthesizes the - card payload from the envelope (real tool name via the existing `resolvedName` stamp, real - arguments as `rawInput`). Working default: synthesize; confirm with a frontend check in - slice 4 that the card renders the same as a relay-gate card does today. If the frontend - keys on anything else in `toolCall`, that is a slice-1 finding, not a wire change. +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.** The plan writes a consumed - dialog-allow into the turn's stored-decisions structure so the relay watcher's - defense-in-depth check passes (plan.md, slice 2). The alternative was skipping relay - enforcement under the flag. Working default: write-through; confirm during slice 2 that - the stored-decisions object is reachable from the responder scope without a layering - violation (both are built in `runTurn`; research.md §3). +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.** `AGENTA_RUNNER_PI_DIALOG_GATE` works without keep-alive but produces a - half-state (better gates, no parking). Working default: independent flags, documented - advice to enable DG only where KA is on. Alternative: make DG imply nothing without KA by - checking KA runner-side. Decide at slice 2 review. +4. **Flag coupling. OPEN, decide at slice 2 review.** `AGENTA_RUNNER_PI_DIALOG_GATE` + (sandbox-side `AGENTA_AGENT_PI_DIALOG_GATE` via `buildPiExtensionEnv`) works without + keep-alive but produces a half-state (better gates, no parking). Working default: + independent flags, documented advice to enable the dialog gate only where keep-alive is + on. Alternative: check keep-alive runner-side and refuse the half-state. -5. **Deny-warm semantics.** 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). That - differs from Claude's deny (reply `reject`, harness continues similarly). Confirm during - slice 4 that continuing live after a deny is the wanted UX, or whether a deny should also - end the turn. Working default: continue live; it matches what Pi does today when a deny - arrives within the poll window. +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. **The `agenta-approval` title as pre-filter.** A user extension could theoretically raise - its own `confirm` titled `agenta-approval` with garbage. Parsing is strict (wrong kind or - version falls through to the fail-closed spec-less path), so the worst case is a paused - turn, not a wrong allow. Working default: accept; revisit if user-authored Pi extensions - ever ship. +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 index a7bbed3310..66a97b3cf9 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/plan.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md @@ -36,11 +36,18 @@ because two codebases parse it. 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 dialog `title` is fixed to -`agenta-approval` and acts as the cheap pre-filter; the envelope is the authority. Parsing is -strict: wrong `kind`, unknown `v`, or unparseable JSON means the request is NOT treated as a -Pi gate and falls through to today's spec-less handling, which pauses fail-closed. Never -guess. +`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 @@ -59,8 +66,13 @@ 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. Fail criteria route back to the -parkable-gates design's Option B fallback; do not improvise. +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) @@ -68,21 +80,47 @@ parkable-gates design's Option B fallback; do not improvise. `buildPiGateEnvelope` (shared with the extension via the existing bundling), and `parsePiGateEnvelope(request) -> {gate, toolName, toolCallId, input} | undefined` (strict, version-checked). -- `acp-interactions.ts`: in `handleRequest` (`:186`), before `buildGateDescriptor`, try - `parsePiGateEnvelope`. On a hit, build the `GateDescriptor` from the envelope and stamp the - emitted `toolCall` payload with the envelope identity (reuse the `resolvedName` stamping at +- **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. -- Reply mapping: `decisionToReply` (`responder.ts:485-493`) falls back to `once`/`reject`, - but the Pi dialog offers `yes`/`no`. Fix generically: choose by option `kind` - (`allow_once`/`reject_once`) from the request's options when the literal ids are absent. - One function, unit-tested against both harnesses' option shapes. +- **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), classification (envelope hit vs spec-less fallthrough), reply mapping. + 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: a Pi dialog gate flows through the responder with the right identity and gets an -instant allow or deny; an ask pauses exactly like today (no parking yet). Ship-safe alone. +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) @@ -92,35 +130,73 @@ instant allow or deny; an ask pauses exactly like today (no parking yet). Ship-s 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. - Double-gate handling: the relay watcher still runs `permissions.decide` on the execution - request (`relay.ts:240-264`). After a dialog allow, that second check must pass. Chosen - mechanism: when the responder answers a Pi dialog gate with allow (instant or resumed), it - records the decision into the turn's stored-decisions structure (the same object `decide` - consults), so the relay check consumes it (`stored.take`). This keeps the relay as - defense-in-depth with one source of truth, instead of disabling it. The alternative - (skip relay enforcement when the dialog plane is on) removes the second check entirely; - rejected because a bug in the extension flag plumbing would then leave zero gates. -- Flag: `AGENTA_RUNNER_PI_DIALOG_GATE` (default off), read runner-side in `run-plan.ts` and - exported into the sandbox env (the existing pattern, research.md §7). One flag controls - both sides coherently because the runner installs the extension per run (research.md §6). -- Tests: extension-level unit tests run through the existing extension test seams; a + request (`relay.ts:240-264`). After a dialog allow, that second check must pass. The + premise holds: the responder and the relay share the SAME `ConversationDecisions` object, + built once per turn (`sandbox_agent.ts:1218`, consumed at `:1225` and `:1268`). But + `ConversationDecisions` exposes only `take`/`peek` over private `decisionQueues` + (`responder.ts:209-237`); there is no public write. The mechanism therefore is: + - Add a FIFO append API to `ConversationDecisions` (a decision pushed onto the queue for a + `(toolName, canonical args)` key, consumed by the next matching `take`). + - On the COLD dialog path (a stored decision answers the dialog instantly), the dialog's + `decide` call consumed one queued decision; append exactly one back for the relay + execution check. Consume-1-append-1, so no stale decision survives to a LATER identical + gate in the same turn. + - The key-parity invariant, stated as a tested invariant, not an assumption: + `envelope.toolName === spec.name` and `envelope.input` is the exact `execute` params + object, so the approved-call key matches what the relay reads (`dispatch.ts:84` writes + `args: params ?? {}`; `relay.ts:245` reads `req.args`). + - The WARM-RESUME path bypasses the responder entirely (the resume calls + `respondPermission` directly, `sandbox_agent.ts:1389`), so nothing on that path appends + to the queue by construction. Slice task: verify whether `extractApprovalDecisions` + already seeds the turn's stored map from the resume request's transcript (the FE folds + the decision into the resume request). If it does, the relay check consumes that seeded + decision and nothing more is needed; if it does not, add an explicit append on the + resume branch just before `:1389`. Either way, cover it with the + dialog-allow-then-relay-execute dispatch test on the RESUME path, not only the instant + path. + This keeps the relay as defense-in-depth with one source of truth. The alternative (skip + relay enforcement when the dialog plane is on) removes the second check entirely; rejected + because a bug in the extension flag plumbing would then leave zero gates. +- Flag: `AGENTA_RUNNER_PI_DIALOG_GATE` (runner side, default off), exported into the sandbox + as `AGENTA_AGENT_PI_DIALOG_GATE` by `buildPiExtensionEnv` (`pi-assets.ts:67-78`, the same + place `AGENTA_AGENT_BUILTIN_GATING` is set), which is where the extension env is actually + built. One flag controls both sides coherently because the runner installs the extension + per run (research.md §6). +- Relay scope note: once the flag is on, a builtin-only run (no custom tools) no longer needs + the relay at all; `useToolRelay` (`run-plan.ts:447`) can be tightened to skip it. Document + now, tighten in this slice if trivial, otherwise record as part of the deletion follow-up. +- 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); a dispatch-level test that a dialog-allowed custom tool executes exactly once through the - relay. + relay (instant path and resume path); consume-1-append-1 accounting. Deliverable: with the flag on, both Pi gates ride the dialog plane end to end; asks still pause-and-destroy (parking arrives in slice 3). Behavior with the flag off is byte-identical. ### Slice 3: park and resume -- `sandbox_agent.ts` `onUserApprovalGate` (`:1321-1338`): record `parkedApproval` for the Pi - dialog gate too, with `gateType: "pi-dialog-permission"` and the envelope identity. 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 resume's `respondPermission` needs the - slice-1 reply mapping (allow -> the `allow_once` option) and, for a custom-tool gate, the - stored-decision record from slice 2 so the follow-on relay execution passes. -- Multi-gate: Pi raises one dialog at a time (the hook blocks the loop), so - `approvalGateCount` stays 1 on this path; assert that in a test rather than assuming it. +- `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)`). @@ -138,13 +214,13 @@ regression tests. | Area | Removed (flag on) | Kept | Added | |---|---|---|---| -| Extension (`agenta.ts`) | the `relayPermissionCheck` call in the builtin hook; the naked `runResolvedTool` for gated custom tools | tool registration; `runResolvedTool` relay EXECUTION (results still flow over the relay files); the old permission path behind the flag for rollback | the dialog gate (`ctx.ui.confirm` + envelope) at both gates | -| Relay (`relay.ts`, `dispatch.ts`) | nothing yet (`relayPermissionCheck` and `handlePermissionRelayRequest` become dead when the flag is on; delete after a bake period, recorded follow-up) | the watcher, execution dispatch, `permissions.decide` defense-in-depth | none | -| Responder seam (`acp-interactions.ts`) | nothing | all pause/park/reply mechanics | envelope detection + `GateDescriptor` from envelope + card payload synthesis | -| Reply mapping (`responder.ts`) | nothing | `once`/`reject` literals for Claude | kind-based option selection (`allow_once`/`reject_once`) | -| Park record (`sandbox_agent.ts`) | nothing | everything | `gateType: "pi-dialog-permission"` + envelope identity in `parkedApproval` | -| Stored decisions | nothing | keying (name + canonical args) | responder writes a consumed dialog-allow into the turn's stored decisions (the double-gate bridge) | -| Config | nothing | `AGENTA_RUNNER_SESSION_KEEPALIVE`, TTLs, pool cap | `AGENTA_RUNNER_PI_DIALOG_GATE` (default off; flip default after slice 4 greens) | +| 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 old permission path behind the flag for rollback | the dialog gate (`ctx.ui.confirm` + envelope) at both gates, non-client specs only | +| Relay (`relay.ts`, `dispatch.ts`, `run-plan.ts`) | nothing yet (`relayPermissionCheck` and `handlePermissionRelayRequest` become dead when the flag is on; delete after a bake period, recorded follow-up; a builtin-only run can stop starting the relay, `useToolRelay` `run-plan.ts:447`) | the watcher, execution dispatch, `permissions.decide` defense-in-depth | 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` | a FIFO append API; consume-1-append-1 on the cold dialog path; warm-resume seeding (verify `extractApprovalDecisions`, else an explicit resume-branch append) | +| Config | nothing | `AGENTA_RUNNER_SESSION_KEEPALIVE`, TTLs, pool cap | `AGENTA_RUNNER_PI_DIALOG_GATE` -> sandbox `AGENTA_AGENT_PI_DIALOG_GATE` via `buildPiExtensionEnv` (`pi-assets.ts:67-78`); default off, flip after slice 4 greens | | 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 @@ -188,10 +264,13 @@ is cold. Flag names: KA = `AGENTA_RUNNER_SESSION_KEEPALIVE`, DG = `AGENTA_RUNNER ## Test inventory (summary) - Unit: envelope build/parse round-trip (incl. the spike's hostile probe string), request - classification (envelope vs spec-less), reply-option mapping (Claude ids, Pi kinds), stored - decision write-through on dialog-allow. -- Dispatch (fake session): Pi ask parks; resume-approve runs original call once; resume-deny - blocks; TTL expiry -> cold; approval-mismatch evicts; `approvalGateCount` stays 1; - double-gate (dialog allow then relay execution) executes exactly once. + 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), decisions FIFO + append + consume-1-append-1 accounting, client-spec exclusion from the dialog gate. +- 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 index d82e19b6cc..49a02dd132 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/research.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/research.md @@ -60,8 +60,21 @@ All merged and live behind `AGENTA_RUNNER_SESSION_KEEPALIVE` (#5156, #5158). (`: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.** `decisionToReply` (`src/responder.ts:485-493`) hard-falls-back to - `"once"`/`"reject"`. This matters below: the Pi dialog's option ids are `"yes"`/`"no"`. +- **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 @@ -114,7 +127,9 @@ facts: (`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`), not Claude's `once`/`reject`. + 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 @@ -141,5 +156,9 @@ and both transports coexist in one extension during rollout. 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. -- Runner env flags are read in `run-plan.ts` and passed into the sandbox env; that is the - existing pattern for the new transport flag. +- The extension's env is built by `buildPiExtensionEnv` (`pi-assets.ts:67-78`), which is + where `AGENTA_AGENT_BUILTIN_GATING` and the relay dir are set; the new transport flag + follows that pattern (runner `AGENTA_RUNNER_PI_DIALOG_GATE` -> sandbox + `AGENTA_AGENT_PI_DIALOG_GATE`), not `run-plan.ts`. +- Whether a run starts the relay at all is `useToolRelay` (`run-plan.ts:447`); a + builtin-only run under the dialog flag no longer needs it. diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/status.md b/docs/design/agent-workflows/projects/pi-approval-parking/status.md index b50c92ade5..44a9dcdd5e 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/status.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/status.md @@ -31,7 +31,52 @@ Source of truth for progress. Keep this current. `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). + 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). ## Next steps From 3bd82064f58ac22e92b04ed177ab46fc4a23decf Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 10 Jul 2026 01:41:59 +0200 Subject: [PATCH 13/13] docs(design): pi-approval-parking landed with no flag; relay permission plane removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog gate shipped as the unconditional Pi behavior (Mahmoud, 2026-07-10): no AGENTA_RUNNER_PI_DIALOG_GATE / AGENTA_AGENT_PI_DIALOG_GATE pair, gateType pi-acp-permission (symmetric with claude-acp-permission), the relay permission plumbing and the planned double-gate FIFO bridge deleted, builtin-only runs start no relay. plan.md carries the final mechanism and the reduced warm/cold matrix (keep-alive is the only remaining switch); research.md marks its §1/§7 relay permission mechanics as the replaced code; open-questions #4 (flag coupling) resolved by there being no flag; status.md records slice-0 evidence, the review rounds, and the scope change. Implementation: PR #5185. Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W --- .../pi-approval-parking/open-questions.md | 9 +- .../projects/pi-approval-parking/plan.md | 93 +++++++------------ .../projects/pi-approval-parking/research.md | 15 ++- .../projects/pi-approval-parking/status.md | 28 +++++- 4 files changed, 75 insertions(+), 70 deletions(-) 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 index 01b53a50b2..2930e02898 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/open-questions.md @@ -30,11 +30,10 @@ state a working default. #1 still gates slice order. explicitly on the resume branch before `sandbox_agent.ts:1389`. Details in plan.md slice 2. -4. **Flag coupling. OPEN, decide at slice 2 review.** `AGENTA_RUNNER_PI_DIALOG_GATE` - (sandbox-side `AGENTA_AGENT_PI_DIALOG_GATE` via `buildPiExtensionEnv`) works without - keep-alive but produces a half-state (better gates, no parking). Working default: - independent flags, documented advice to enable the dialog gate only where keep-alive is - on. Alternative: check keep-alive runner-side and refuse the half-state. +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 diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/plan.md b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md index 66a97b3cf9..35d42d8440 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/plan.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/plan.md @@ -135,49 +135,28 @@ non-matching title takes today's path untouched. 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. -- Double-gate handling: the relay watcher still runs `permissions.decide` on the execution - request (`relay.ts:240-264`). After a dialog allow, that second check must pass. The - premise holds: the responder and the relay share the SAME `ConversationDecisions` object, - built once per turn (`sandbox_agent.ts:1218`, consumed at `:1225` and `:1268`). But - `ConversationDecisions` exposes only `take`/`peek` over private `decisionQueues` - (`responder.ts:209-237`); there is no public write. The mechanism therefore is: - - Add a FIFO append API to `ConversationDecisions` (a decision pushed onto the queue for a - `(toolName, canonical args)` key, consumed by the next matching `take`). - - On the COLD dialog path (a stored decision answers the dialog instantly), the dialog's - `decide` call consumed one queued decision; append exactly one back for the relay - execution check. Consume-1-append-1, so no stale decision survives to a LATER identical - gate in the same turn. - - The key-parity invariant, stated as a tested invariant, not an assumption: - `envelope.toolName === spec.name` and `envelope.input` is the exact `execute` params - object, so the approved-call key matches what the relay reads (`dispatch.ts:84` writes - `args: params ?? {}`; `relay.ts:245` reads `req.args`). - - The WARM-RESUME path bypasses the responder entirely (the resume calls - `respondPermission` directly, `sandbox_agent.ts:1389`), so nothing on that path appends - to the queue by construction. Slice task: verify whether `extractApprovalDecisions` - already seeds the turn's stored map from the resume request's transcript (the FE folds - the decision into the resume request). If it does, the relay check consumes that seeded - decision and nothing more is needed; if it does not, add an explicit append on the - resume branch just before `:1389`. Either way, cover it with the - dialog-allow-then-relay-execute dispatch test on the RESUME path, not only the instant - path. - This keeps the relay as defense-in-depth with one source of truth. The alternative (skip - relay enforcement when the dialog plane is on) removes the second check entirely; rejected - because a bug in the extension flag plumbing would then leave zero gates. -- Flag: `AGENTA_RUNNER_PI_DIALOG_GATE` (runner side, default off), exported into the sandbox - as `AGENTA_AGENT_PI_DIALOG_GATE` by `buildPiExtensionEnv` (`pi-assets.ts:67-78`, the same - place `AGENTA_AGENT_BUILTIN_GATING` is set), which is where the extension env is actually - built. One flag controls both sides coherently because the runner installs the extension - per run (research.md §6). -- Relay scope note: once the flag is on, a builtin-only run (no custom tools) no longer needs - the relay at all; `useToolRelay` (`run-plan.ts:447`) can be tightened to skip it. Document - now, tighten in this slice if trivial, otherwise record as part of the deletion follow-up. +- 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); a - dispatch-level test that a dialog-allowed custom tool executes exactly once through the - relay (instant path and resume path); consume-1-append-1 accounting. + 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: with the flag on, both Pi gates ride the dialog plane end to end; asks still -pause-and-destroy (parking arrives in slice 3). Behavior with the flag off is byte-identical. +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 @@ -214,31 +193,30 @@ regression tests. | 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 old permission path behind the flag for rollback | the dialog gate (`ctx.ui.confirm` + envelope) at both gates, non-client specs only | -| Relay (`relay.ts`, `dispatch.ts`, `run-plan.ts`) | nothing yet (`relayPermissionCheck` and `handlePermissionRelayRequest` become dead when the flag is on; delete after a bake period, recorded follow-up; a builtin-only run can stop starting the relay, `useToolRelay` `run-plan.ts:447`) | the watcher, execution dispatch, `permissions.decide` defense-in-depth | none | +| 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` | a FIFO append API; consume-1-append-1 on the cold dialog path; warm-resume seeding (verify `extractApprovalDecisions`, else an explicit resume-branch append) | -| Config | nothing | `AGENTA_RUNNER_SESSION_KEEPALIVE`, TTLs, pool cap | `AGENTA_RUNNER_PI_DIALOG_GATE` -> sandbox `AGENTA_AGENT_PI_DIALOG_GATE` via `buildPiExtensionEnv` (`pi-assets.ts:67-78`); default off, flip after slice 4 greens | +| 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. Flag names: KA = `AGENTA_RUNNER_SESSION_KEEPALIVE`, DG = `AGENTA_RUNNER_PI_DIALOG_GATE`. +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+DG 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 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 on, DG off | Exactly today: relay-poll gates, pause destroys the session, cold decision-map resume. | -| KA off, DG on | The dialog gate still works (instant allow/deny from the responder; better card identity), but an ask pauses and destroys the session (no pool), and the dialog dies with it, fail-closed. Cold resume as today. Acceptable, but flip DG on only where KA is on to avoid a confusing half-state; state this in the rollout note. | -| Both off | Byte-identical to today. | +| 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 @@ -247,9 +225,10 @@ is cold. Flag names: KA = `AGENTA_RUNNER_SESSION_KEEPALIVE`, DG = `AGENTA_RUNNER 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. -- **Flag order.** Ship slices 1-3 dark, then enable `AGENTA_RUNNER_PI_DIALOG_GATE` on the dev - stack with keep-alive already on, run slice 4, then default it on. The old relay permission - path stays in the code one release as the rollback lever, then gets deleted (follow-up). +- **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). @@ -258,16 +237,16 @@ is cold. Flag names: KA = `AGENTA_RUNNER_SESSION_KEEPALIVE`, DG = `AGENTA_RUNNER 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. Delete `relayPermissionCheck` / `handlePermissionRelayRequest` after the bake period. -3. Daytona parking (keep-alive slice 3) picks up Pi parking for free; verify then. +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), decisions FIFO - append + consume-1-append-1 accounting, client-spec exclusion from the dialog gate. + 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 diff --git a/docs/design/agent-workflows/projects/pi-approval-parking/research.md b/docs/design/agent-workflows/projects/pi-approval-parking/research.md index 49a02dd132..a35eb56f95 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/research.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/research.md @@ -4,6 +4,11 @@ Everything here is verified against `services/runner/src` as of 2026-07-09 (post #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. @@ -157,8 +162,8 @@ and both transports coexist in one extension during rollout. - `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` and the relay dir are set; the new transport flag - follows that pattern (runner `AGENTA_RUNNER_PI_DIALOG_GATE` -> sandbox - `AGENTA_AGENT_PI_DIALOG_GATE`), not `run-plan.ts`. -- Whether a run starts the relay at all is `useToolRelay` (`run-plan.ts:447`); a - builtin-only run under the dialog flag no longer needs it. + 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 index 44a9dcdd5e..8b5876a2bc 100644 --- a/docs/design/agent-workflows/projects/pi-approval-parking/status.md +++ b/docs/design/agent-workflows/projects/pi-approval-parking/status.md @@ -78,8 +78,30 @@ open-questions.md the same day. All findings were code-verified by the reviewer: 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 0: the live daemon confidence run (reuse the committed spike assets). -2. Coordination check with JP on where the pool/park machinery lives before slice 3. -3. Slices 1-4 per plan.md. +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).