Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions hosting/docker-compose/ee/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,12 @@ services:
AGENTA_RUNNER_HOST: ${AGENTA_RUNNER_HOST:-0.0.0.0}
AGENTA_RUNNER_TOKEN: ${AGENTA_RUNNER_TOKEN:?AGENTA_RUNNER_TOKEN is required}
PI_CODING_AGENT_DIR: /pi-agent
# Claude Code harness (subscription / runtime_provided auth). CLAUDE_CONFIG_DIR is the
# writable config/state mount the run-plan requires; CLAUDE_CODE_OAUTH_TOKEN is the login
# (macOS keeps the Claude login in Keychain, which can't be bind-mounted, so the token is
# the local auth path). Both resolve from the loaded env file.
CLAUDE_CONFIG_DIR: /claude-config
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
# The API as reached FROM this container — by its compose service name, over the
# network (the same `http://api:8000` the workers use), NOT the public
# `http://localhost/api` (which doesn't resolve inside a container). Used by the
Expand Down Expand Up @@ -430,6 +436,10 @@ services:
# Read-write on purpose: the harness refreshes its own OAuth token here and the new
# token must persist back to the host login (a copy would discard it).
- ${HOME}/.pi/agent:/pi-agent:rw
# Claude Code config/state (writable). The login comes from CLAUDE_CODE_OAUTH_TOKEN; this
# dir holds the settings/session state the harness reads and refreshes. Dedicated dir (not
# ~/.claude) so the container never touches the host's real Claude config.
- ${HOME}/.agenta-claude-config:/claude-config:rw
# === NETWORK ============================================== #
networks:
- agenta-network
Expand Down
32 changes: 23 additions & 9 deletions services/runner/src/engines/sandbox_agent/acp-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export interface AttachPermissionResponderInput {
* gracefully because a paused Claude turn never resolves `session.prompt()` on its own.
*/
onPause?: () => void;
/**
* Collect-then-pause seam for parkable APPROVAL gates (keep-alive only). Parallel gates arrive
* staggered, so instead of ending the turn on the first, each gate calls this to (re)arm a
* debounced pause — the turn parks once the harness goes quiet, with the whole batch of gates
* emitted. When absent (cold path, client tools) the gate falls back to `onPause` (pause now).
*/
onScheduleApprovalPause?: () => void;
log?: (msg: string) => void;
/** Called with the ACP tool-call id when a gate pauses the turn. */
onPausedToolCall?: (id: string) => void;
Expand Down Expand Up @@ -112,6 +119,7 @@ export function attachPermissionResponder({
latch,
serverPermissions = new Map(),
onPause,
onScheduleApprovalPause,
log,
onPausedToolCall,
onCreateInteraction,
Expand Down Expand Up @@ -155,35 +163,41 @@ export function attachPermissionResponder({
gate: GateDescriptor,
gateType: ParkedApprovalGateType,
): void => {
// 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).
// Record the parkable gate so a keep-alive resume can answer it via `respondPermission`. In
// collect-then-pause mode every gate is recorded (the whole parallel batch parks together).
const gateToolCallId = stringValue(req?.toolCall?.toolCallId);
const eventId = interactionEventId(id, gateToolCallId);
onUserApprovalGate?.({
permissionId: id,
toolCallId: gateToolCallId ?? "",
toolName: gate.toolName,
args: gate.args,
interactionToken: interactionEventId(id, gateToolCallId),
interactionToken: eventId,
gateType,
});
if (!latch.tryAcquire()) return;
const toolCallId = stringValue(req?.toolCall?.toolCallId);
const eventId = interactionEventId(id, toolCallId);
if (toolCallId) onPausedToolCall?.(toolCallId);
// Keep-alive (`onScheduleApprovalPause` present): emit EVERY concurrent gate and debounce the
// pause, so a parallel batch surfaces as N approval cards that park together. Cold path keeps
// the one-pause-per-turn latch — a single gate emits, and a multi-gate turn tears down cold as
// before (its siblings are settled and replayed as retry nudges).
const collecting = Boolean(onScheduleApprovalPause);
if (!collecting && !latch.tryAcquire()) return;
// A duplicate delivery of the SAME gate (same id + tool-call) must not double-emit its card.
if (createdInteractionIds.has(eventId)) return;
if (gateToolCallId) onPausedToolCall?.(gateToolCallId);
run.emitEvent({
type: "interaction_request",
id: eventId,
kind: "user_approval",
payload: {
toolCallId,
toolCallId: gateToolCallId,
toolCall: stampResolvedName(req?.toolCall, gate),
availableReplies: stringArray(req?.availableReplies),
options: req?.options,
},
});
createdInteractionIds.add(eventId);
onCreateInteraction?.(eventId, gate.toolName, gate.args, "user_approval");
onPause?.();
(onScheduleApprovalPause ?? onPause)?.();
};

const pauseClientTool = (
Expand Down
18 changes: 5 additions & 13 deletions services/runner/src/engines/sandbox_agent/environment-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,14 @@ import { rmSync } from "node:fs";

import { apiBase } from "../../apiBase.ts";

import {
resolveRunSessionId,
type AgentRunRequest,
} from "../../protocol.ts";
import { resolveRunSessionId, type AgentRunRequest } from "../../protocol.ts";
import { type ClientToolOutcome } from "../../responder.ts";
import type { ClientToolRelay } from "../../tools/client-tool-relay.ts";
import {
agentMountPath,
signAgentMountCredentials,
} from "./agent-mount.ts";
import { agentMountPath, signAgentMountCredentials } from "./agent-mount.ts";
import { createToolCallCorrelationIndex } from "./client-tools.ts";
import { buildDaemonEnv, resolveDaemonBinary } from "./daemon.ts";
import { conciseError } from "./errors.ts";
import {
signSessionMountCredentials,
type MountCredentials,
} from "./mount.ts";
import { signSessionMountCredentials, type MountCredentials } from "./mount.ts";
import {
buildPiExtensionEnv,
configurePiSessionWorkspace,
Expand Down Expand Up @@ -347,8 +338,9 @@ export async function prepareEnvironmentSetup(
closeToolMcp: undefined,
currentTurn: undefined,
lastTurnToolCallIds: [],
parkedApproval: undefined,
parkedApprovals: [],
approvalGateCount: 0,
deferredSiblingSettled: false,
destroyed: false,
destroy: async () => {},
clearTurn: () => {},
Expand Down
42 changes: 42 additions & 0 deletions services/runner/src/engines/sandbox_agent/pause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class PendingApprovalPauseController {
private readonly pausedToolCallIds = new Set<string>();
private resolvePause: (() => void) | undefined;
private eventDrain: Promise<void> = Promise.resolve();
private pauseTimer: ReturnType<typeof setTimeout> | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Debounce timer is never cancelled on non-paused turn exit — can destroy a later turn's live session.

pauseTimer is only cleared inside pause()/schedulePause() themselves. If a turn that armed this timer exits via any other path (run-limit trip, thrown error, etc.) before the collect window elapses, the timer keeps running against this now-orphaned controller. Since env (in run-turn.ts) is a pooled/reused object whose parkedApprovals is reset at the top of the next runTurn call, the stale timer's eventual pause() call can find env.parkedApprovals.length === 0 and fall through to env.mcpAbort.abort() + env.sandbox.destroySession?.(env.session.id) — tearing down a completely different, currently-live turn's session.

Add a cancellation method that only clears the timer (no side effects), and invoke it from run-turn.ts's exit paths that did not pause (this requires hoisting pause out of the try block so finally can reach it, similar to otel/activeTurn).

🔒 Proposed fix
+  /** Cancel any armed collect-window timer without pausing. Call on every turn-exit path
+   * that did NOT pause, so a stale timer from THIS turn can never fire against whatever
+   * turn/session is live on the shared `env` afterwards. */
+  cancelSchedule(): void {
+    if (this.pauseTimer) {
+      clearTimeout(this.pauseTimer);
+      this.pauseTimer = undefined;
+    }
+  }
+
   pause(): void {
-    let otel: ReturnType<typeof createSandboxAgentOtel> | undefined;
-    let activeTurn: CurrentTurn | undefined;
+    let otel: ReturnType<typeof createSandboxAgentOtel> | undefined;
+    let activeTurn: CurrentTurn | undefined;
+    let pause: PendingApprovalPauseController | undefined;
...
-    const pause = new PendingApprovalPauseController(() => {
+    pause = new PendingApprovalPauseController(() => {
   } finally {
     runLimits.dispose();
+    if (pause && !pause.active) pause.cancelSchedule();
     await activeTurn?.toolRelay?.stop().catch(() => {});
     if (activeTurn) activeTurn.toolRelay = undefined;
   }

Also applies to: 28-58


readonly signal: Promise<void>;

Expand All @@ -24,8 +25,49 @@ export class PendingApprovalPauseController {
});
}

/**
* Debounced pause: (re)arm a timer so the turn pauses only once the harness has been quiet for
* `delayMs`. Parallel approval gates arrive staggered (~0.5s apart, measured), so pausing on the
* FIRST would strand the rest; each incoming gate calls this again to extend the window, and the
* turn parks once with the whole batch. A late gate that lands after the timer fires falls back to
* the straggler path (force-settled → cold retry), exactly as a non-collected gate does today.
* `pause()` (client tool, non-parkable, teardown) still ends the turn immediately.
*/
schedulePause(delayMs: number): void {
if (this.pendingApproval) return; // already paused; the batch is closed
// No collection window: pause on this gate immediately (synchronous), i.e. today's
// one-gate-per-turn behavior. Also the deterministic path for tests.
if (delayMs <= 0) {
this.pause();
return;
}
if (this.pauseTimer) clearTimeout(this.pauseTimer);
this.pauseTimer = setTimeout(() => {
this.pauseTimer = undefined;
this.pause();
}, delayMs);
// Do not keep the event loop alive solely for the collection window.
this.pauseTimer.unref?.();
}

/**
* Cancel any armed collect-window timer without pausing. Turn-scoped, like `runLimits.dispose()`:
* `run-turn.ts` calls it in `finally` so a timer this turn armed can never fire against the pooled
* `env` after the turn exited some other way (run-limit trip, throw). No-op once `pause()` ran.
*/
dispose(): void {
if (this.pauseTimer) {
clearTimeout(this.pauseTimer);
this.pauseTimer = undefined;
}
}

pause(): void {
if (this.pendingApproval) return;
if (this.pauseTimer) {
clearTimeout(this.pauseTimer);
this.pauseTimer = undefined;
}
this.pendingApproval = true;
let destroyResult: Promise<void> | void | undefined;
try {
Expand Down
Loading
Loading