Skip to content

feat(runner): preserve the warm sandbox after Stop - #6496

Merged
mmabrouk merged 25 commits into
feat/session-controlfrom
spike/session-cancel-warm
Sep 4, 2026
Merged

feat(runner): preserve the warm sandbox after Stop#6496
mmabrouk merged 25 commits into
feat/session-controlfrom
spike/session-cancel-warm

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 2, 2026

Copy link
Copy Markdown
Member

Agent-generated, low weight. Draft. Do not merge before Mahmoud reviews it.

A user Stop now sends the real harness cancel, waits for the harness to answer, ends the turn as cancelled (kept in the terminal done record), and parks the sandbox instead of destroying it. The vendored sandbox-agent client is patched (eight lines, cancelSession) to allow session/cancel; the daemon inside the sandbox needs no change, so Daytona needs no new snapshot. A stopped session parks under its own window, AGENTA_RUNNER_SESSION_STOPPED_TTL_MS, which defaults to the ordinary idle window. On a Stop the runner now also reaps the shell child a Codex turn leaves behind, through the sandbox process API, on both providers. A stopped turn now writes the same continuity record a completed turn writes, so a runner restart hydrates the native harness session from the durable mount. At shutdown the runner releases its Redis owner claims through a release_owner field on the heartbeat, so a restarted runner is admitted at once instead of after the 120 s owner lease.

Evidence

Date Provider Harness Commit Scenario Result Timing
2026-09-02 local Pi 11af505e3e Stop during a sleep tool, then continue parked, same sandbox, recalled turn one cancel settled 14 ms, resume 2.3 s
2026-09-02 local Codex 11af505e3e same parked, same sandbox, recalled turn one cancel settled 22 ms, resume 12.2 s
2026-09-02 local Pi 11af505e3e cancel budget forced to 1 ms (negative control) destroyed, cold rebuild 8.0 s
2026-09-03 local Codex 9110c08000 (integration) Stop during sleep 300, inspect the parked sandbox shell child alive 56 s, gone at 61 s (60 s window), survived into the warm continuation
2026-09-03 local Pi 9110c08000 same child killed within 0.2 s
2026-09-03 local Claude Code 9110c08000 same child killed within 0.2 s
2026-09-03 local Codex 9e21fba4ee (this branch, port 8980) Stop during sleep 300 with the reap child killed at the Stop, sandbox parked, next message warm with no sandbox_start, new shell command ran
2026-09-03 local Claude Code 9110c08000 (integration, PR #6506) Stop during output, during a shell tool, on a waiting approval; continue all pass, same sandbox and same native session 165 ms, 155 ms, 111 ms

| 2026-09-03 | local | Pi | 9110c08000 (integration) | Stop, docker restart the runner, continue | sandbox and harness identity lost, conversation survived only through the client transcript, refused 90 to 150 s | admitted at 123.6 s |
| 2026-09-03 | local | Pi | 0b8fe254b1 (continuity record) | same, server-side reconstruction off, empty client transcript | hydrated ... loaded=true, mode=load, same native session, codeword recalled; sandbox id new on the local provider by design | refused 112 s (owner lease, next row) |
| 2026-09-03 | local | Pi | 5a10e6b100 (owner release) | Stop, hold 30 s, SIGTERM restart, continue | two claims released 154 ms after the signal, admitted on the first attempt, native session loaded | 19.6 s after the restart |
| 2026-09-03 | local | Pi | 5a10e6b100 | same with SIGKILL (control) | no handler runs, refused five times until the lease expired | admitted at 67.5 s |

Not verified: Daytona for any harness on this branch (the Daytona rows are on PR #6505 and #6506 when they land), and the reap's ps call against the Daytona snapshot image.

Cause of the Codex leak: the Codex adapter is a thin bridge over a Rust codex app-server subprocess whose cancel handler (codex-acp/dist/index.js:30089) only sends turn/interrupt; the shell child is a child of the Rust process and nobody kills it. Pi kills a detached process group (pi-coding-agent/dist/utils/shell.js:189); Claude Code kills inside its binary.

Tests

  • cd services/runner && pnpm run build:extension && pnpm test: 162 files, 2687 tests pass (head 5a10e6b100).
  • pnpm exec vitest run --project unit tests/unit/cancel-continuity.test.ts: 6 pass; tests/unit/session-ownership-release.test.ts: part of the 14 new runner tests.
  • cd api && pytest oss/tests/pytest/unit/sessions/ -q: 491 pass, 41 skipped on a bare host (7 new for the release branch).
  • cd services/runner && pnpm vitest run tests/unit/reap-exec.test.ts: 18 pass.
  • cd services/runner && pnpm typecheck: clean.

Branch head

5a10e6b100 on spike/session-cancel-warm. Base agent/session-execution-rfc. Reports: docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md on this branch, and evidence-2026-09-03/child-process-cleanup.md plus cancel-continuity.md on PR #6505.

https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV

Reap fix (2026-09-04)

On the local provider every sandbox is a child of the runner in one process table. The Codex reap disabled itself when it saw more than one codex app-server, so a stopped Codex shell child survived the park window whenever two sessions ran at once. Commit cce2b21bc3 scopes the reap to the sandbox's own daemon by an exact --port match, keeping the descendant and turn-age checks. Daytona was never affected (one daemon per machine). Tests: reap-exec 22, runner 2,691.

Early-stop endings (2026-09-04)

Five Stops within a second showed that a Stop which lands while the sandbox is still being acquired never starts the turn, and nobody wrote the turn's ending; the execution settled with zero records. The runner now persists the stopped ending itself when the run returns without a terminal record (9bbbf4fb39), and a genuine acquire failure keeps its error before the done instead of reading as a clean finish (44630051a5). Reviewed by the agent that found the defect. Runner tests: 2,694.

Continuity and acquire preemption (2026-09-04, afternoon)

The browser pass found that a local session evicted between turns forgot its history: the rebuild reported an empty native session as loaded and the runner then sent only the last message. Three commits: 4bcb5f5a34 takes last-message-only only when the native load is verified by observed session/load events and otherwise replays the reconstructed transcript; 570fb79aab tracks whether the native history is durable (Daytona always, local once the cwd mount succeeded) and gates verification on it; fa8c9f5cfc lets a Stop that lands during a slow sandbox acquire abort the acquire and report within the delivery timeout, destroying a late create and pausing a late reconnect. Reviewed by the agent that found the defect; runner 2,734 tests. Non-blocking follow-up: the Daytona-only ~/.pi/agent/sessions mount is dead weight now that Pi's transcript lives under the durable cwd on both providers.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 4, 2026 5:02pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • release/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Team

Run ID: b21aef49-7d5e-4f70-b9df-890400866155

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Summary

Summary by CodeRabbit

  • New Features
    • Stopping a run now sends a cooperative cancellation request and can keep the sandbox warm for faster resume.
    • Stopped sessions receive a clear terminal status and extended keep-alive window.
    • Session ownership is released when a runner shuts down, allowing another runner to take over promptly.
    • Session history is verified before reusing it; otherwise, the conversation is safely reconstructed.
  • Bug Fixes
    • Improved cancellation during sandbox setup, credential checks, and storage mounting.
    • Added safeguards for missing terminal events and leaked processes after stopped runs.
  • Documentation
    • Added design documentation for session control and live events.

Walkthrough

The change adds cooperative Stop handling that can preserve a settled sandbox, abortable environment acquisition with cleanup, native-history verification for continuation, explicit runner ownership release, Codex process reaping, terminal-record safeguards, and design documentation with unit coverage.

Changes

Session control lifecycle

Layer / File(s) Summary
Heartbeat ownership release
api/oss/src/core/sessions/streams/*, services/runner/src/sessions/alive.ts, services/runner/src/server.ts, services/runner/tests/unit/session-ownership-release.test.ts
Heartbeats can release affinity ownership. The runner records confirmed claims and releases them during shutdown with bounded cleanup.
Stop settlement and sandbox parking
services/runner/src/engines/sandbox_agent/*, services/runner/src/server.ts, services/runner/src/tracing/otel.ts, services/runner/tests/unit/*, web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts
A labelled user Stop sends session/cancel. Confirmed cancellation produces a cancelled terminal result and parks the sandbox. Unconfirmed cancellation destroys it. Codex leaked processes are reaped when possible.
Abortable environment acquisition
services/runner/src/environment/*, services/runner/src/engines/sandbox_agent/{agent-mount,credential-preflight,environment,mount}.ts, services/runner/tests/unit/{acquire-abort,credential-preflight,sandbox-agent-mount,sandbox-agent-orchestration}.test.ts
Abort signals now cover provider calls, credential probes, mounts, tunnel discovery, and environment setup. Late resources receive destroy, pause, or unmount cleanup.
Native history continuity
services/runner/src/environment/harness-session-lifecycle.ts, services/runner/src/engines/sandbox_agent/{runtime-contracts,environment,run-turn}.ts, services/runner/src/lifecycle/session-coordinator.ts, services/runner/tests/unit/{continuation,environment-units,sandbox-agent-orchestration}.test.ts
Session loads report native-history verification. Cold resumes replay the reconstructed transcript unless prior native conversation events were observed. Parked sessions use configurable stopped-session TTLs.
Design record and agent patch
docs/design/session-control-and-live-events/*, services/runner/patches/sandbox-agent@0.4.2.patch
The design folder adds requirements, decisions, research, RFC, plan, status, handoff, and records-invariant documents. The sandbox-agent patch adds remote loading and cancellation support.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🟠 High · up to fa8c9

Stopped sessions can retain unsafe sandbox state, while resumed sessions can lose conversation context. These paths should be corrected before merge.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Runner
  participant SandboxAgent
  participant Harness
  participant SessionStore
  Client->>Runner: request Stop
  Runner->>SandboxAgent: abort with user-stop reason
  SandboxAgent->>Harness: session/cancel
  Harness-->>SandboxAgent: settle prompt
  SandboxAgent->>SessionStore: persist cancelled terminal record
  SandboxAgent-->>Runner: return cancelSettled
  Runner->>Runner: park or destroy sandbox
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 61.84% which is sufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 76 functions across 41 files. (12 skipped: …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the primary change: preserving the warm sandbox after a user Stop. This matches the main objective of the changeset.
Description check ✅ Passed The description directly explains the warm-sandbox behavior and related runner, sandbox-agent, ownership-release, continuity, and testing changes.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch spike/session-cancel-warm

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

  • Risky: services/runner/src/engines/sandbox_agent/engine.ts:49 - Codex can satisfy this generic settled-cancel predicate while leaving its in-flight shell process alive, so the runner parks a sandbox that is not actually idle.
  • Missing environments: Claude Code was not tested because no Anthropic key was available. Daytona was not tested. Relevant runner and web test jobs are skipped on the exact head.

User problem

Before this change, Stop aborted the runner's wait but did not cancel the harness prompt. Teardown then deleted the sandbox, causing a cold start and losing the native harness session on the next message. The intended user outcome is a fast Stop followed by a warm continuation in the same sandbox and harness session.

How the change works

The runner labels the heartbeat interruption, sends ACP session/cancel through a small patch to sandbox-agent@0.4.2, waits up to 10 seconds for the open prompt to settle, records stopReason: "cancelled", and parks only when cancellation was sent and the prompt resolved. Unsettled cancellations still destroy the environment. A separate stopped-session time-to-live setting defaults to the existing idle window.

The state test is incomplete for Codex. Prompt settlement proves that the harness answered. It does not prove that subprocesses started by an in-flight tool stopped.

Issue coverage

This PR does not link a GitHub issue, and its timeline has no linked issue. I therefore cannot claim that it fully or partially solves any issue based on repository evidence. PR #6505 references this spike as one part of the overnight work, and integration PR #6506 includes it, but those are pull requests rather than issue coverage.

Dependencies and overlap

This is not independent relative to main. It is stacked on draft PR #6495 at base SHA c72d1f00e5a589a1f0f5e3f60b19862e68dfe315, and the reviewed delta starts at merge base f4a6834ba6af9d9201c225d6cb94020393f03537.

PR #6503 is directly stacked on this branch and adds the durable Stop command and direct-call delivery path. PR #6506 overlaps by integrating #6496, #6503, and the other session-control slices, with additional conflict fixes and combined gate tests. The current heartbeat callback still receives one undifferentiated ownership-loss signal for cancel, steer, and kill. The durable command work in #6503 is therefore relevant to reviewing the final control semantics, but it does not remove the Codex subprocess defect documented here.

Tests and missing environments

At exact head f5b1ae6244885ecfe70191effe9ed5f15fc9e943:

  • Local runner typecheck passed.
  • Seven focused runner unit files passed, 198 tests total. They cover cancellation helpers, park policy, teardown, pool dispatch, steer mount loss, and lifecycle routing.
  • The agenta-chat transcript suite passed, 52 tests.
  • git diff --check passed.
  • GitHub format, lint, contribution, and secret checks passed.
  • GitHub runner, runner acceptance, runner integration, services, and web unit jobs were skipped.
  • The PR report records live local Pi and Codex runs. It records no Claude Code run and no Daytona run.
  • The recorded Codex probe fails the required idle-state invariant because the shell child remains alive after Stop.

Recommendation

Hold. Do not ship generic warm parking for Codex until cancellation also terminates its in-flight tool process, or gate the warm-park path by harness and keep deleting Codex sandboxes after Stop. After that fix, review this as a stack with #6503 and the integration behavior in #6506.

Comment thread services/runner/src/engines/sandbox_agent/engine.ts
@mmabrouk mmabrouk changed the title [overnight] feat(runner): keep the sandbox warm when a user stops a turn (Spike A) feat(runner): preserve the warm sandbox after Stop Sep 3, 2026
@mmabrouk
mmabrouk changed the base branch from agent/session-execution-rfc to main September 4, 2026 07:04

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT-GENERATED review, low weight. A read guide and a few places to look; not an approval.

Read in this order

  1. services/runner/src/sessions/stop-signal.ts — the abort label that makes a user Stop distinguishable from every other abort.
  2. services/runner/src/engines/sandbox_agent/engine.ts shouldPark — the one place that decides park vs delete.
  3. services/runner/src/engines/sandbox_agent/cancel-turn.ts — cancel the harness turn and wait for it to settle.
  4. services/runner/src/engines/sandbox_agent/reap-exec.ts — kill the shell child a stopped Codex turn leaves running.
  5. services/runner/src/sessions/alive.ts + streams service.py release_owner — hand the affinity key back on shutdown.

Top findings

  • One coherent runner-side change; strong tests across all five threads.
  • No feature flag, but the fallback is structural: an unpatched client has no cancelSession, so the cancel is unsettled and the sandbox is destroyed exactly as before.
  • The park decision rests on USER_STOP_ABORT_REASON being applied at exactly one call site; the reap kills by pid with a small pid-reuse window (bounded, best-effort).
  • stoppedTtlMs is inert and carries an open decision for Mahmoud (move a stopped local session to the 600s approval window).

Comment thread services/runner/src/engines/sandbox_agent/engine.ts
Comment thread services/runner/src/engines/sandbox_agent/run-turn.ts
Comment thread services/runner/src/engines/sandbox_agent/reap-exec.ts
Comment thread services/runner/src/engines/sandbox_agent/session-identity.ts
Comment thread api/oss/src/core/sessions/streams/service.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/runner/src/server.ts (1)

654-654: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve persistence failures until the session is marked incomplete.

flush() already consumes takePersistFailures(sessionId). Line 654 then reads zero, so noteRecordsIncomplete() is skipped after exhausted persistence retries. A later turn can reconstruct from an incomplete transcript. Return the dropped count from flush() or mark the session incomplete before clearing it.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Team

Run ID: a22a0113-5102-4897-bbef-23fc49527469

📥 Commits

Reviewing files that changed from the base of the PR and between ded2cc3 and fa8c9f5.

⛔ Files ignored due to path filters (1)
  • services/runner/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (53)
  • api/oss/src/core/sessions/streams/dtos.py
  • api/oss/src/core/sessions/streams/service.py
  • api/oss/tests/pytest/unit/sessions/test_heartbeat_release_owner.py
  • docs/design/session-control-and-live-events/README.md
  • docs/design/session-control-and-live-events/context.md
  • docs/design/session-control-and-live-events/decisions.md
  • docs/design/session-control-and-live-events/plan.md
  • docs/design/session-control-and-live-events/records-invariants.md
  • docs/design/session-control-and-live-events/requirements.md
  • docs/design/session-control-and-live-events/research.md
  • docs/design/session-control-and-live-events/rfc.md
  • docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md
  • docs/design/session-control-and-live-events/status.md
  • docs/design/session-control-and-live-events/tonight-handoff.md
  • services/runner/patches/sandbox-agent@0.4.2.patch
  • services/runner/src/engines/sandbox_agent/agent-mount.ts
  • services/runner/src/engines/sandbox_agent/cancel-turn.ts
  • services/runner/src/engines/sandbox_agent/credential-preflight.ts
  • services/runner/src/engines/sandbox_agent/engine.ts
  • services/runner/src/engines/sandbox_agent/environment-setup.ts
  • services/runner/src/engines/sandbox_agent/environment.ts
  • services/runner/src/engines/sandbox_agent/mount.ts
  • services/runner/src/engines/sandbox_agent/provider.ts
  • services/runner/src/engines/sandbox_agent/reap-exec.ts
  • services/runner/src/engines/sandbox_agent/run-turn.ts
  • services/runner/src/engines/sandbox_agent/runtime-contracts.ts
  • services/runner/src/engines/sandbox_agent/session-identity.ts
  • services/runner/src/engines/sandbox_agent/teardown.ts
  • services/runner/src/environment/abortable-sandbox-provider.ts
  • services/runner/src/environment/acquire-abort.ts
  • services/runner/src/environment/harness-session-lifecycle.ts
  • services/runner/src/environment/mount-lifecycle.ts
  • services/runner/src/lifecycle/session-coordinator.ts
  • services/runner/src/protocol.ts
  • services/runner/src/server.ts
  • services/runner/src/sessions/alive.ts
  • services/runner/src/sessions/stop-signal.ts
  • services/runner/src/tracing/otel.ts
  • services/runner/tests/unit/acquire-abort.test.ts
  • services/runner/tests/unit/cancel-continuity.test.ts
  • services/runner/tests/unit/continuation.test.ts
  • services/runner/tests/unit/credential-preflight.test.ts
  • services/runner/tests/unit/environment-units.test.ts
  • services/runner/tests/unit/harness-cancel-park.test.ts
  • services/runner/tests/unit/reap-exec.test.ts
  • services/runner/tests/unit/sandbox-agent-mount.test.ts
  • services/runner/tests/unit/sandbox-agent-orchestration.test.ts
  • services/runner/tests/unit/sandbox-lifecycle.test.ts
  • services/runner/tests/unit/server.test.ts
  • services/runner/tests/unit/session-ownership-release.test.ts
  • services/runner/tests/unit/session-pool.test.ts
  • services/runner/tests/unit/teardown.test.ts
  • web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Comment thread docs/design/session-control-and-live-events/decisions.md Outdated
Comment thread docs/design/session-control-and-live-events/decisions.md Outdated
Comment thread docs/design/session-control-and-live-events/records-invariants.md Outdated
Comment thread docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md Outdated
Comment thread docs/design/session-control-and-live-events/tonight-handoff.md Outdated
Comment thread services/runner/src/engines/sandbox_agent/mount.ts
Comment thread services/runner/src/engines/sandbox_agent/reap-exec.ts Outdated
Comment thread services/runner/src/environment/harness-session-lifecycle.ts Outdated
Comment thread services/runner/src/sessions/alive.ts
Comment thread web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts Outdated
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📘 Docs preview

Status ✅ Ready
Preview https://pr-6496-agenta-docs-preview.mahmoud-637.workers.dev/docs
Inspect Actions run
Commit bbae565b7b9d93588605fb38b24535421219b43a

This comment updates in place on every push.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

A user Stop aborted the run signal and nothing else. The turn ended with
stopReason "cancelled", shouldPark answered false for every aborted run, and
the sandbox was deleted, so the next message paid a cold start and lost the
native harness session. The abort never told the harness anything either: it
only made the runner stop waiting, leaving an open prompt and a running tool
that only the teardown ever stopped.

Cancel the harness first, then park. On the cancelled path the turn now sends
the ACP session/cancel notification for the live session and waits a bounded
time for the harness to answer its open prompt. ACP requires the agent to end
that prompt with stopReason "cancelled", so a settled prompt is the harness
reporting it is idle. Only a settled cancel parks; a cancel that cannot be
sent, or that the harness never answers inside the budget, leaves the
environment unknown and still destroys it.

sandbox-agent refuses a manual session/cancel ("Use destroySession(sessionId)
instead"). The guard is in the TypeScript client only, so the pnpm patch adds
cancelSession(id), which sends the same managed cancel destroySession sends
without marking the session record destroyed. The daemon inside the sandbox
proxies ACP and holds no such rule, so no Daytona snapshot rebuild is needed.

The cancel deliberately does not abort env.mcpAbort. That controller belongs to
the environment, not the turn, and a parked environment must keep its tool-MCP
server; the approval-park path already skips it for the same reason.

Client-disconnect behavior is unchanged. The clientGone check moved above the
abort check so a disconnect still destroys whatever the abort says.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Answers the six questions the work package asked, with path:line evidence:
where the session/cancel guard lives (the vendored client, not the daemon),
what the harness reports after a cancel, what happens to a running tool call,
why every cancellation path destroyed the sandbox before this change, the
eight-line client patch, and why Daytona needs no rebuilt snapshot.

Also records the live protocol and its results for Pi and Codex, a negative
control that forces the settle budget to 1 ms and shows the destroy path, the
recommended settlement timeout for D-016, a release-gate cell, and the three
things the spike did not cover.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The first cut inferred the Stop from `stopReason: "cancelled"`, but the turn
sets that value whenever the run signal aborts, whatever aborted it. Any
future `controller.abort()` anywhere in the runner would then silently start
parking sandboxes nobody had checked, which is the failure the teardown
allowlist exists to prevent.

The one call site that means a cooperative Stop, the heartbeat interrupt in
server.ts, now labels its abort, and shouldPark requires that label alongside
the cancelled stop reason and the settled harness cancel. The mechanism is the
standard AbortController.abort(reason), so nothing new is threaded through the
engine, the coordinator or the turn.

Also from the review:

- A stopped session parks on its own window, defaulting to the 600 s approval
  window locally because the user is about to type, and to the ordinary 120 s
  idle window on Daytona where a parked sandbox is billed compute. One named
  field, one env var, so the two windows collapse again with one value.
- The terminal done record carries stopReason "cancelled" as well as "paused".
  Without it a stopped turn is indistinguishable from a completed one in
  Postgres, so neither the frontend nor the release gate can tell a Stop from a
  finish. Kept as a two-value allowlist so a harness-reported end_turn cannot
  start appearing there by accident.
- Corrects the comment claiming the abort severs the harness fetch. It does
  not: the signal reaches the client's health wait only, never the ACP
  transport, which is why the cancelled branch has to send a real
  session/cancel.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…windows

Answers the reviewers' remaining questions with measurements rather than
expectations.

The finding that needs a decision: a stopped Codex turn leaves its shell
command running inside the parked sandbox, and Pi does not. Measured by
cancelling a running sleep and having the next turn list processes; one probe
returned two leftovers at once, from two different sessions. Running the same
scenario down the destroy path left none, so parking is what makes the child
survive rather than something this change merely revealed. The fix belongs in
the Codex ACP bridge, which this repo already patches on both image surfaces,
and unlike the runner-side cancel it would need a Daytona snapshot rebuild.

Also records the current park windows and the new stopped-session window, why
the abort now carries an explicit reason, that cancel, steer and kill are
indistinguishable to the runner until the durable command plane lands, the
terminal-record fix with its Postgres evidence, and two release-gate assertions
including one that fails on Codex today on purpose.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The runner change that carries `stopReason: "cancelled"` through to the
terminal done record had live evidence but no test. Three runner assertions
now pin it: a Stop carries the reason, a pause still does, and a completed
turn plus every harness-reported reason carry nothing. The last one is the
point of the two-value allowlist, so it is the one worth having.

On the frontend, transcript reconstruction reads only "paused"
(transcriptToMessages), so a cancelled done falls through to the ordinary
terminator and closes the turn like a completed one. That is the behaviour we
want, and "the new value is inert here" is a claim worth a test rather than a
comment, so two cases pin it: a stopped turn does not swallow the next turn
the way a pause does, and it is not marked paused.

Verified: 159 runner files / 2650 tests, and 52 agenta-chat transcript tests.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… window

The first cut shipped a decision that is not mine to make: it defaulted a
stopped local session to the 600 s approval window. The field now defaults to
the ordinary idle window on both providers, so introducing it changes no
timing at all, and it exists only so the value is one named setting with one
env var when somebody decides to move it.

The recommendation stays, written where the reader who changes it will be
standing: make it the approval window on the local provider, because a user
who stops is about to type and the 60 s idle window can throw the sandbox away
while they are still writing. Daytona would not follow, because a parked
Daytona sandbox is billed compute and its 120 s window is already that
decision. Try it with AGENTA_RUNNER_SESSION_STOPPED_TTL_MS.

A third test covers the env var moving the stopped window without disturbing
the ordinary idle one.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…rk window

Adds one table near the top saying what was actually tested and on what, so a
reader does not have to infer coverage from the prose: Pi and Codex live on
the local sandbox, Claude not tested for want of an Anthropic key, Daytona not
tested at all, and per harness what the cancel does to the in-flight tool.
Also records that before this change the abort sent no cancel to Claude Code
or Codex at all, which is why the difference between the two harnesses was
invisible until now.

Corrects the park-window section and the matching open question to the value
that actually ships. The stopped window defaults to the ordinary idle window
and changes no timing; moving it to 600 s locally is a recommendation for
Mahmoud, not something this spike decided.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…parked sandbox

A Stop now parks the sandbox instead of deleting it, and the delete is what used
to kill whatever the turn had started. Measured on the integration stack, local
sandbox provider: Pi and Claude Code kill their shell child inside 0.2 s of the
cancel, and Codex leaves it running until the park window closes.

Codex differs because its shell is not the ACP adapter's child. The adapter is a
JavaScript bridge over a Rust `codex app-server` subprocess, the shell child is a
direct child of that Rust process, and the bridge's cancel only sends the
`turn/interrupt` request. The interrupt works: the prompt settles cancelled in
about 48 ms. The Rust core simply abandons the exec, and that core is a stripped
vendored binary we pin rather than build.

Reap it from the runner through the sandbox daemon's one-off process API, so the
fix ships in the runner image alone and behaves the same on the local and the
Daytona provider. A patch to the bridge would have to do the same /proc walk in a
bundle installed into the sandbox image, and would ship only through a Daytona
snapshot rebuild.

Two rules keep the reap off anything a warm session needs: only descendants of
the `codex app-server` process are candidates, and only those younger than the
turn that was stopped. An stdio MCP server starts with the session, before the
prompt, so it is never selected. The app-server itself is never a candidate, so
the native harness session survives exactly as before.

The reap is best effort and cannot change the park decision. A sandbox that would
have been parked is still parked when the reap cannot run, because trading a warm
session away for a tidier process table is the wrong trade.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…d children

Every rounding error in the reap must make it kill less, not more. On a COLD
first turn Codex clones its plugin repository about a second before the prompt is
issued, so a ceiling round put a `git fetch` the SESSION owns one second inside
the window meant for the turn's own exec. A child born in the first second of a
turn is not physically possible, because the model has to emit a tool call first,
so flooring costs nothing and closes the overlap.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…ed turn writes

A user Stop kept the sandbox warm, but only in this process. `hydrateHarnessSessionFromDurable`
re-seeds the continuity store from the durable turn ledger only when the latest `session_turns`
row carries both `agent_session_id` and `end_time`, and a cancelled turn wrote neither: it took
the `invalidateContinuity` branch and never called `complete`. So the row stayed open forever,
the hydration branch written for exactly this case never fired, and the first runner restart
after a Stop cost the session its native harness session. The next message rebuilt cold and the
conversation survived only as the client's replayed transcript.

The rule is now the harness's own confirmation, not the park decision. A settled cancel is the
same proof that earns the warm park in `shouldPark`: the harness answered the cancelled prompt,
so it is idle and its native transcript holds a short but finished turn. That is a faithful
resume point, so it advances the in-memory pointer and completes the ledger row on the same
path a completed turn uses. A pause, and a cancel the harness never confirmed, still drop the
record and fall back to cold replay.

An unlabelled abort whose cancel settled now writes the record too, even though the sandbox is
deleted. The native session lives on the durable cwd, so the next turn can load it into a fresh
sandbox, which is the whole point of the durable mirror.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… 120 s lease

`owner:session:<id>` is claimed by every heartbeat and was released by nothing, and the API's
`claim_owner` deliberately never steals from a live owner. So a runner that exited while
holding claims locked each of those sessions out of its own replacement for the rest of
OWNER_TTL_SECONDS. On the local sandbox provider that is a two-minute outage after every
restart: the new replica refuses with "replica X is not the owner of session Y ... Refusing to
cold-start on the wrong host", measured at 112 to 123 s against the 120 s lease.

The API gains one optional field on the beat it already serves, `release_owner`. It runs
first, before the superseded check and before any lock is read or written, and does exactly
one thing: `clear_owner`, which is release-if-owner, so a beat from a replica that no longer
holds the session is a no-op and can never take affinity from a live one. No turn lock and no
stream row is touched, because a departing runner asserts no liveness and no turn.

The runner learns which sessions it owns from the beats it already sends: every beat the API
answers with this replica's own id records the session and the credential that spoke for it,
and a beat this replica lost records nothing. The SIGTERM handler then hands each claim back,
after the sandboxes are destroyed so a session whose sandbox is still going does not yet look
free, and bounded so it can never hold the process past the grace period.

A SIGKILL reaches no handler, so the 120-second lease stays the fallback for that case and for
an unreachable API. Nothing else changes: `release_owner` defaults to false, and an ordinary
beat still claims.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Anchor app-server discovery to the sandbox-agent server whose exact port matches the current sandbox, so concurrent warm local sessions cannot make the reap ambiguous. Preserve the existing descendant, turn-age, and maximum-candidate safety gates.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Propagate mount cancellation, clean mounts that finish late, and treat a non-zero kill exit as a failed Codex reap. Prove the leaked Codex shell child is gone before the warm sandbox parks.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Capture the persist event count before session/load and inspect only events emitted by that attempt. Older session/update records can no longer validate an empty native replay.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Detect both smart and legacy truncation markers before reconstructing conversation history. A partial durable record now fails closed instead of becoming model context.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Record long polling as the selected milestone transport, give each open gate a unique ID, and document the current Codex reap and continuity behavior. Make truncation refusal and pair-level release verification explicit.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Keep the reconstruction invariant in one concise comment while the assertions carry the behavioral detail.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Compose the newer credential differential preflight with the acquire signal around both the sandbox probe and polling delay. Stop now exits promptly without dropping the release branch probe behavior.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Reject only legacy whole-body truncation records that lack an event type.

Keep structure-preserving smart-truncated tool results replayable and cover both record shapes in reconstruction tests.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Keep the normal Stop assertion scoped to done events now that admitted session runs also emit a turn correlation event.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
@mmabrouk
mmabrouk force-pushed the spike/session-cancel-warm branch from 24fa55a to bbae565 Compare September 4, 2026 17:01
@mmabrouk
mmabrouk merged commit 40e8f80 into feat/session-control Sep 4, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant