Skip to content

feat(runner): hold approval gates open on the live session (Claude ACP gates)#5158

Merged
mmabrouk merged 5 commits into
big-agentsfrom
feat/session-keepalive-approvals
Jul 8, 2026
Merged

feat(runner): hold approval gates open on the live session (Claude ACP gates)#5158
mmabrouk merged 5 commits into
big-agentsfrom
feat/session-keepalive-approvals

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 8, 2026

Copy link
Copy Markdown
Member

Context

This is slice 2 of the session-keepalive plan (#5153), stacked on #5156. Slice 1 keeps a session alive across normal turn boundaries. This slice extends it to the approval pause, which is where both 2026-07-07 production failures happened: the runner destroyed the session at the moment a tool call needed approval, so a fresh model had to re-issue the call from a lossy text transcript. Its regenerated arguments drifted (three approval rounds for one curl) or it restarted the task entirely (five rounds, zero commits). Full analysis: cold-replay-failure-report.md.

With this slice, a Claude ACP permission gate parks the live session holding the still-pending permission request and the suspended prompt. When the human clicks approve or deny, the runner answers that parked request directly. The original tool call runs with its original byte-exact arguments. No model re-issues anything, so argument drift and task restart cannot happen on the live path.

What changed

  • acp-interactions.ts: a new onUserApprovalGate callback fired only on the harness ACP permission path (never for client tools or Pi gates). This is the sole seam that marks a gate as parkable.
  • sandbox_agent.ts: park mode in the pause callback. The F-024 sibling settle runs unconditionally on every pause; only the session-destroying steps are skipped when parking. The resume branch answers the parked gate via respondPermission, seeds the parked tool call into the new turn's trace, resolves the durable interaction, and awaits the original suspended prompt.
  • session-pool.ts: the awaiting_approval state with the 10-minute TTL, checkoutApproval (removes the session from the map so a racing request can never destroy an in-flight resume), and strict decision matching by the parked gate's toolCallId (reusing the cold path's envelope parsing).
  • server.ts: the resume dispatch branch (validate config, history, credential epoch, and the tool call id, then resume), park finalizers, and a watcher that evicts a parked session immediately if its held prompt rejects (a dead sandbox does not occupy a slot for the full TTL).
  • 28 new approval-parking tests, plus the review-round fixes' tests (717 unit tests at this PR's head).

Scope and risk

  • Claude ACP permission gates ONLY park. Pi custom-tool relay gates, Pi builtin gates, and client-tool MCP pauses stay on the cold path, tested. See the per-gate-type table in the plan (Q7) for why each cannot park in v1.
  • The cold decision-map fallback is untouched (responder.ts unchanged). It still handles: approval TTL expiry (default 10 minutes), runner restart, pool miss, non-Claude gates, and any resume mismatch. Nothing gets worse than today.
  • Same flag as slice 1, off by default. Flag off keeps today's pause behavior byte-identical.
  • A resumed approval executes with the original turn's baked environment (credentials, secrets, sandbox); the new turn owns streaming and tracing. The credential epoch bounds staleness: expired or rotated credentials evict to cold instead of resuming.
  • Multiple simultaneous gates do not park in v1 (cold, as today).

How to QA

Prerequisites: slice 1 QA passing (flag on, local sandbox, playground conversation), a Claude-harness agent with a gated tool (needs an anthropic provider key). Confirm mount signing works first (a [keepalive] park line proves it); without it keep-alive silently runs all-cold by design.

  1. With AGENTA_RUNNER_SESSION_KEEPALIVE=true, ask the agent to run a gated tool. The approval prompt appears in the UI; the runner logs [keepalive] park-approval ... and the turn ends paused.
  2. Click approve within 10 minutes (AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS). The runner logs [keepalive] resume-approve ...; the tool runs immediately with the original arguments; no second approval round appears. Compare with today: no [HITL] resume state decision-map lines on this path.
  3. Repeat with deny: [keepalive] resume-reject ...; the model handles the rejection in the same live session.
  4. Repeat and wait past 10 minutes before clicking: [keepalive] approval-ttl-expire ...; the click then resumes through today's cold decision-map path (the [HITL] lines reappear); behavior matches current production.
  5. Run the same gated-tool scenario on a Pi-harness agent: no park ([keepalive] non-claude-gate-no-park), cold behavior exactly as today.

Test command: cd services/runner && pnpm test (717 unit tests at this PR's head: everything from slice 1 and big-agents unchanged, plus the approval-parking and review-round tests) and pnpm run typecheck.

Edge cases covered by tests: park-and-answer approve and deny, approval TTL expiry degrading to the cold decision map, non-Claude gates never parking, multi-gate pauses never parking, pre-pause sibling tool calls settled as not-executed on every pause path, resume mismatch (edited history, changed config, rotated credentials) evicting to cold, awaiting_approval sessions exempt from LRU eviction, a duplicate approval racing an in-flight resume (gate answered exactly once, resume never destroyed mid-flight), and a parked prompt rejection evicting promptly.

https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv

@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 8, 2026 3:32pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 summarizes the main change: keeping Claude ACP approval gates open on live runner sessions.
Description check ✅ Passed The description directly matches the changeset and explains the approval park/resume keepalive behavior in detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/session-keepalive-approvals

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.

// Signal the parkable gate BEFORE the latch so a keep-alive resume can record the pending
// permission id and the multi-gate detector counts every pending gate (not just the first).
const gateToolCallId = stringValue(req?.toolCall?.toolCallId);
onUserApprovalGate?.({

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.

This callback is the single seam that can mark a gate as parkable, and it fires only on the harness ACP permission path. pauseClientTool never calls it, and Pi gates never reach this file, so the v1 scope (Claude ACP gates only) is enforced structurally rather than by checks scattered through the dispatch.

// here would leave them as orphaned open parts whenever the dispatch later refuses the park
// (multi-gate, pool full) — `env.destroy()` does not re-run it. The exclusion keeps the
// gated (paused) call itself open, so the live resume is untouched.
run.settleOpenToolCalls(

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.

The F-024 sibling settle runs unconditionally, park mode or not. Latch-loser tool calls announced before the winning gate can never execute this turn, and env.destroy() does not re-run this sweep, so skipping it when the dispatch later refuses the park (multi-gate, pool full) would orphan those parts. The exclusion keeps the gated call itself open for the live resume. This exact gap was the top finding of both review passes.

// non-parkable pause (flag off, Pi relay/builtin gate, client tool) never records
// `parkedApproval`, so it still tears down here exactly as today.
if (opts.approvalParkMode && env.parkedApproval) return;
// Abort any in-flight loopback `tools/call` (a paused Claude client tool) BEFORE the

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.

Park mode skips only the session-destroying steps (mcpAbort, destroySession). If the dispatch then decides not to park, env.destroy() still runs them, so nothing is lost; if it parks, the live session keeps the pending gate and the suspended prompt.

});
promptPromise = Promise.resolve(opts.resume.promptPromise);
promptPromise.catch(() => {});
await env.session.respondPermission(

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.

The whole point of the feature: the runner answers the gate the harness is still holding, so the ORIGINAL tool call executes with its original byte-exact arguments. No model re-issues the call, which removes the argument-drift and task-restart failures from the 2026-07-07 production turns at the root. The new turn only streams and traces; nothing is re-baked into the live session.

* most once: only the checkout winner ever holds the parked permission id. After the resume
* turn, `repark` re-inserts only if the slot is still empty (see there).
*/
checkoutApproval(key: string): LiveSession<E> | undefined {

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.

checkoutApproval removes the session from the map, so the resume turn owns the environment exclusively. A duplicate approval or a racing fresh message just misses the pool and runs cold (today's concurrent-request semantics) instead of destroying an environment that is mid-way through executing the approved tool. This also makes respondPermission at-most-once: only the checkout winner holds the parked permission id.

* exactly the gate that parked. An incoming reply for a different id, or a plain user message,
* yields undefined and the dispatch degrades to cold.
*/
export function approvalDecisionForToolCall(

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.

Decision matching reuses the cold path's envelope parsing (responder.approvalDecisionOf) and matches strictly by the parked gate's toolCallId. The cold decision-map machinery itself is untouched and remains the fallback for TTL expiry, restarts, misses, and non-Claude gates.

// try/catch handles the failure) or after a supersede is not ours and does nothing. `evict` is
// idempotent through the session's one destroy, so no double-destroy is possible. The promise
// already carries runTurn's swallowing catch, so no unhandled rejection is introduced.
const watchParkedPrompt = (env: SessionEnvironment): void => {

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.

A parked session whose held prompt rejects (sandbox died mid-park) is evicted immediately instead of occupying a pool slot for the full 10-minute approval TTL. The handler is identity-checked, so a rejection that arrives after a successful checkout does nothing; the resume turn's own error handling covers that case.

return false;
}
if ((env.approvalGateCount ?? 0) > 1) {
klog(`multi-gate-no-park key=${key} gates=${env.approvalGateCount}`);

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.

v1 parks only the single-gate case. Two or more unanswered gates at pause time refuse the park and tear down cold, exactly as today. The greppable multi-gate-no-park line is how QA distinguishes this deliberate refusal from a miss.

mmabrouk added a commit that referenced this pull request Jul 8, 2026
Adds AGENTA_RUNNER_SESSION_KEEPALIVE, _SESSION_TTL_MS,
_SESSION_APPROVAL_TTL_MS, and _SESSION_POOL_MAX to the canonical agent
env-var list. The flags land across the two keep-alive slices (#5156,
#5158); this stacked lane carries the doc so both are in by the time it
applies.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
@mmabrouk

mmabrouk commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Fix: approval resume no longer requires re-minted credentials to match (the "approve twice" bug)

Live symptom. On the dev box every tool approval had to be clicked twice. The first approval never took effect; the turn restarted and asked again.

Root cause. When a Claude ACP permission gate parks a live session in awaiting_approval, the resume dispatch was requiring the incoming resume request's config fingerprint and credential epoch to equal the parked session's. But every approval reply is a fresh /run the backend mints with freshly minted short-lived material: gateway/Composio secret values and a per-turn tool-callback bearer. The credential epoch hashes those secret values plus the callback auth, and the config fingerprint can embed the same per-turn tokens, so on a real approval these practically never match the parked session. The dispatch read that as approval-mismatch (credentials) or (config), evicted the perfectly good parked live session, ran the turn cold, and cold-replay argument drift re-prompted the user. Log evidence from session 8110bba2 showed a park then a mismatch (credentials|config) + evict on every resume except the one time validation happened to pass (resume-approve ... reply=once), which proves the live resume path works when validation lets it through.

What the fix changes. On the awaiting_approval branch only, the config-fingerprint and credential-epoch equality checks are dropped. The parked live process already holds its own resolved credentials, baked at acquire time; the resume request only delivers the human's yes/no, and re-minted per-turn material on that request says nothing about the parked environment's validity. The branch still keeps the checks that actually bound the parked environment: the approval-decision match, the history fingerprint (an edited transcript still degrades to cold), and a new hard mount-expiry bound (if the parked mount credentials are past expiry the durable cwd can no longer be written, so it still evicts to cold). The idle-continuation branch keeps its full validation unchanged. Separately, the ambiguous credentials mismatch log reason is now split into credentials-expired vs credentials-rotated so future diagnosis works straight from the logs.

Test evidence. pnpm test = 708 passed (was 704), pnpm run typecheck clean. New/updated unit tests: an approval resume carrying a different credential epoch AND a different config fingerprint but a matching decision + history now resumes LIVE (respondPermission called exactly once, no evict, no re-acquire); an expired parked mount still evicts to cold; edited history and wrong tool-call id still evict; the idle-continuation branch keeps full validation. The one existing test that asserted the old eviction-on-changed-config behavior for the approval branch was updated to assert the corrected live-resume behavior. New session-pool tests cover mountCredentialsExpired and the credentialEpochMismatch reason split.

Files: services/runner/src/server.ts, services/runner/src/engines/sandbox_agent/session-pool.ts, and the two runner unit test files. Commit c4e9679ef4.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
services/runner/tests/unit/session-keepalive-approval.test.ts (1)

442-542: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Missing coverage for approval-resume failure paths.

The suite thoroughly exercises validation mismatches, TTL, disconnect, double-answer, and prompt rejection, but there is no case where the resume runTurn itself throws (resume-threw, Lines 618-624 of server.ts) or returns !ok (resume-failed, Lines 625-629). Those paths call pool.evictIfCurrent on a session already removed from the map by checkoutApproval; a test asserting env1.destroyed === 1 (and calls.acquire === 2) after a throwing/failed resume would confirm the checked-out env is actually torn down and not leaked.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b60a7a66-0994-4741-9ad5-358b0c1aabd1

📥 Commits

Reviewing files that changed from the base of the PR and between 8dd2ee5 and c4e9679.

📒 Files selected for processing (7)
  • docs/design/agent-workflows/documentation/running-the-agent.md
  • services/runner/src/engines/sandbox_agent.ts
  • services/runner/src/engines/sandbox_agent/acp-interactions.ts
  • services/runner/src/engines/sandbox_agent/session-pool.ts
  • services/runner/src/server.ts
  • services/runner/tests/unit/session-keepalive-approval.test.ts
  • services/runner/tests/unit/session-pool.test.ts

* short-lived material every time, so they practically never match), but an expired mount means
* the parked cwd can no longer be written, so it must still evict to cold.
*/
export function mountCredentialsExpired(

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.

On the mountCredentialsExpired check: this is a liveness check, not an authorization check, and that is the key difference from the project-scope case.

A parked session's cwd is a live geesefs (FUSE) mount backed by short-lived credentials minted at acquire time. If those credentials expire while the session sits parked, the cwd becomes a dead mount: the resumed tool would fail on its first write to the durable store. So before resuming, we verify the parked environment is still physically operable. Expiry means the environment is no longer usable (the mount's own concern), so we evict to cold and re-acquire with fresh credentials. That is unlike the project-scope case, which borrowed the mount for identity and is being moved off it (follow-up 5, in progress).

No code change needed. The structural-cleanup follow-up will rename this to make the intent read at the call site, e.g. parkedCwdMountExpired.

@mmabrouk
mmabrouk marked this pull request as ready for review July 8, 2026 15:20
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. feature labels Jul 8, 2026
@mmabrouk
mmabrouk force-pushed the feat/session-keepalive-pool branch from 8dd2ee5 to 2084078 Compare July 8, 2026 15:23
mmabrouk added a commit that referenced this pull request Jul 8, 2026
Adds AGENTA_RUNNER_SESSION_KEEPALIVE, _SESSION_TTL_MS,
_SESSION_APPROVAL_TTL_MS, and _SESSION_POOL_MAX to the canonical agent
env-var list. The flags land across the two keep-alive slices (#5156,
#5158); this stacked lane carries the doc so both are in by the time it
applies.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
@mmabrouk
mmabrouk force-pushed the feat/session-keepalive-approvals branch from 7551a87 to 2cac1dc Compare July 8, 2026 15:23
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:XXL This PR changes 1000+ lines, ignoring generated files. labels Jul 8, 2026
mmabrouk added 5 commits July 8, 2026 17:30
…tes only)

Slice 2 of the session-keepalive plan (#5153), stacked on the pool slice.
A Claude ACP permission gate now parks the live session (awaiting_approval,
10-minute TTL) holding the pending permission id and the suspended prompt.
The human's approve or deny answers the parked request directly via
respondPermission, so the original tool call executes with its original
byte-exact arguments; no model re-issues anything. This removes the
argument-drift and task-restart failure classes from the live path.

- Pi relay gates, Pi builtin gates, and client-tool MCP pauses never park
  (cold as today, asserted by tests); the cold decision-map fallback is
  untouched and covers TTL expiry, restarts, and every mismatch
- the F-024 latch-loser sibling settle runs unconditionally on every pause
- checkoutApproval removes the session from the map so a racing request
  can never destroy an in-flight resume; respondPermission is at-most-once
- a parked prompt rejection evicts the session immediately

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
Adds AGENTA_RUNNER_SESSION_KEEPALIVE, _SESSION_TTL_MS,
_SESSION_APPROVAL_TTL_MS, and _SESSION_POOL_MAX to the canonical agent
env-var list. The flags land across the two keep-alive slices (#5156,
#5158); this stacked lane carries the doc so both are in by the time it
applies.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
…o match

Every tool approval on the dev box needed approving twice. Root cause: the
awaiting_approval dispatch branch required the resume request's config
fingerprint and credential epoch to equal the parked session's. But each
approval reply is a fresh /run the backend mints with freshly minted
short-lived material (gateway/Composio secret values, a per-turn tool-callback
bearer), so the credential epoch -- and often the config fingerprint that can
embed those tokens -- practically never matched. The parked live session was
evicted on every approval, the turn ran cold, and cold-replay argument drift
re-prompted the user.

Fix: the awaiting_approval branch keeps the approval-decision match, the
history fingerprint, and a hard mount-expiry bound (an expired parked mount can
no longer write its cwd, so it still evicts to cold), but drops the config and
credential-epoch equality checks. The parked process already holds its own
credentials baked at acquire time; the resume only delivers the human yes/no.
The idle-continuation branch keeps its full validation, with the ambiguous
credentials mismatch reason split into credentials-expired vs
credentials-rotated for log diagnosis.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
Restore time-based run limits in the split turn path (P1), skip the cold
retry after a failed live turn already streamed to the client (P2), and
await a replaced same-key session's teardown before parking (P2).

Rides the stacked approvals lane because the streaming-retry fix edits the
slice-2 approval-resume branch; lands with the stack bottom-up into big-agents.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
…ct helpers)

Readability-only, no behavior change. Renames the SessionEnvironment `env2` to
`environment` and extracts the two session-lifetime listeners into documented
`routeSessionEventToActiveTurn` / `routePermissionRequestToActiveTurn` helpers, so
`acquireEnvironment` reads as clear steps and the demux data flow is obvious.
Addresses Mahmoud's #5156 review note that the event handling was hard to read.
723 tests unchanged, typecheck clean, reviewer confirmed no semantic drift.

Lands on the stacked approvals lane (top of the keep-alive stack) because the working
tree is the stack's applied state and GitButler attributes the edit to the top lane;
it rides into big-agents with the stack.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
@mmabrouk
mmabrouk force-pushed the feat/session-keepalive-pool branch from 2084078 to 8d774de Compare July 8, 2026 15:30
@mmabrouk
mmabrouk force-pushed the feat/session-keepalive-approvals branch from 2cac1dc to 7ef57be Compare July 8, 2026 15:30
@mmabrouk
mmabrouk changed the base branch from feat/session-keepalive-pool to big-agents July 8, 2026 15:33
@mmabrouk
mmabrouk merged commit 26404f1 into big-agents Jul 8, 2026
15 of 21 checks passed
@mmabrouk mmabrouk mentioned this pull request Jul 8, 2026
12 tasks
@mmabrouk

mmabrouk commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Follow-up: the approval TTL default is now 5 minutes (300000 ms), landed in #5178. The design decision is recorded in session-keepalive/open-questions.md item 2.

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

Labels

feature size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant