Skip to content
Open
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
41 changes: 36 additions & 5 deletions services/runner/src/engines/sandbox_agent/run-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import {
APPROVED_EXECUTION_RESULT_UNKNOWN,
createSandboxAgentOtel,
INTERRUPTED_BY_USER,
TOOL_NOT_EXECUTED_PAUSED,
} from "../../tracing/otel.ts";
import {
Expand Down Expand Up @@ -94,6 +95,10 @@ export async function runTurn(
): Promise<AgentRunResult> {
const { plan, logger, deps } = env;
const sessionId = env.sessionId;
// Race marker for a user Stop (the control-plane `cancel`/`steer` command drops the alive lock, the
// heartbeat aborts `signal`). Distinct from PAUSED/RUN_LIMIT_TRIPPED so the turn ends CLEANLY
// (honest interrupted transcript, keep-warm) instead of falling through to the error catch.
const CANCELLED = Symbol("cancelled");
const continuityStore = deps.sessionContinuityStore ?? sessionContinuityStore;
const turnStartedAt = new Date().toISOString();
// `turn_index` is a true conversation-turn counter, not an acquire counter: it advances once per completed turn across every environment serving the session.
Expand Down Expand Up @@ -743,18 +748,34 @@ export async function runTurn(
);
promptPromise.catch(() => {});
}
// A user Stop aborts `signal`, which severs the harness fetch (rejecting the prompt). We want a
// clean cancel, not an error: resolve the race to CANCELLED both when the abort event lands first
// AND when the prompt rejection lands first while already aborted, so the outcome is deterministic
// regardless of ordering. A real (non-abort) prompt rejection is re-thrown into the shared catch.
const cancelled = new Promise<typeof CANCELLED>((resolve) => {
if (signal?.aborted) resolve(CANCELLED);
else signal?.addEventListener("abort", () => resolve(CANCELLED), { once: true });
});
const raced = await Promise.race([
promptPromise,
promptPromise.then(
(value) => value,
(err) => (signal?.aborted ? CANCELLED : Promise.reject(err)),
),
pause.signal.then(() => PAUSED),
runLimitTripped.then(() => RUN_LIMIT_TRIPPED),
cancelled,
]);
// A tripped run-limit ends the turn as an error: throw into the shared catch below so the
// trace is flushed and the caller's teardown reclaims the (wedged) sandbox.
if (raced === RUN_LIMIT_TRIPPED) {
throw new Error(runLimitReason ?? "run limit tripped");
}
const stopReason =
raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason;
raced === CANCELLED
? "cancelled"
: raced === PAUSED || pause.active
? "paused"
: (raced as any)?.stopReason;
// Terminalization drains queued gates, classifies pause-time completions, and gives allowed
// executions their original per-call bound before the orphan sweep closes the turn.
if (stopReason === "paused") {
Expand Down Expand Up @@ -813,7 +834,16 @@ export async function runTurn(
);
}
}
const result = raced === PAUSED ? undefined : raced;
if (stopReason === "cancelled") {
// The user Stopped the turn: let any in-flight frames settle, honor real completions that
// already arrived, then settle every STILL-open tool call with the interrupt sentinel so the
// transcript closes HONESTLY — no orphaned "running" parts, no synthetic success. A deliberate
// human halt is not retryable; steer surfaces any new instruction as the next turn's prompt.
await pause.waitForEventDrain().catch(() => {});
settleBufferedPausedCompletions();
run.settleOpenToolCalls(() => false, INTERRUPTED_BY_USER);
}
const result = raced === PAUSED || raced === CANCELLED ? undefined : raced;
// A parkable pause this turn: hand the still-pending prompt promise to EVERY parked record so a
// later resume can await the same continuation (there is one prompt per turn, so every gate
// shares it). Set after the race so `promptPromise` exists.
Expand Down Expand Up @@ -869,6 +899,7 @@ export async function runTurn(
// in-memory resume pointer or complete the durable ledger row.
if (
stopReason !== "paused" &&
stopReason !== "cancelled" &&
env.continuityTurnIndex !== undefined &&
sessionId
) {
Expand All @@ -894,8 +925,8 @@ export async function runTurn(
{ authorization: turnLedgerContext.authorization, log: logger },
).catch(() => {});
}
} else if (stopReason === "paused") {
// A pause stopped mid-turn, after the harness may have written a partial turn natively.
} else if (stopReason === "paused" || stopReason === "cancelled") {
// A pause/cancel stopped mid-turn, after the harness may have written a partial turn natively.
invalidateContinuity(sessionId, plan.harness, deps);
}

Expand Down
7 changes: 7 additions & 0 deletions services/runner/src/tracing/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export const TOOL_NOT_EXECUTED_PAUSED = `${DEFERRED_NOT_EXECUTED_PREFIX}: paused
export const APPROVED_EXECUTION_RESULT_UNKNOWN =
"APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call.";

/** Terminal result stamped on every open tool call when the USER stops (cancels) the turn. Unlike
* the pause sentinels this is a deliberate human halt, not a scheduling artifact: the call was cut
* off and may or may not have run, so the model must not silently retry it. Steer (stop + a new
* instruction) surfaces the user's guidance separately as the next turn's prompt. */
export const INTERRUPTED_BY_USER =
"INTERRUPTED_BY_USER: the user stopped the turn before this call finished; it may not have completed. Do not retry it unless the user asks again.";

// ---------------------------------------------------------------------------
// Shared, process-wide tracing infrastructure
// ---------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions services/runner/tests/unit/session-keepalive-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,27 @@ function updateEvent(update: Record<string, unknown>) {
}

describe("runTurn: real approval park + respondPermission resume", () => {
it("a user Stop (signal abort) ends the turn cleanly as cancelled, not an error", async () => {
const { deps } = pausableHarness();
const acquired = await acquireEnvironment(engineReq, deps);
assert.equal(acquired.ok, true);
if (!acquired.ok) return;

// A turn is in flight; the user hits Stop → the control-plane cancel drops the alive lock →
// the heartbeat aborts the run signal (the fake harness prompt stays pending, as a severed
// fetch would). The turn must resolve to a CLEAN cancel, not fall through to the error catch.
const controller = new AbortController();
const p = runTurn(acquired.env, engineReq, undefined, controller.signal, {
approvalParkMode: true,
});
await flush();
controller.abort();
const r = await p;

assert.equal(r.ok, true, "a clean cancel is not an error");
assert.equal(r.stopReason, "cancelled");
});

it("parks a Claude ACP gate (session alive), then answers it live and streams the continuation", async () => {
const { calls, deps, captured } = pausableHarness();
const acquired = await acquireEnvironment(engineReq, deps);
Expand Down
32 changes: 24 additions & 8 deletions web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "react"

import {
commandSessionStream,
killSession,
revalidateSessionMountsAtom,
revalidateSessionRecordsAtom,
Expand Down Expand Up @@ -1070,11 +1071,21 @@ const AgentConversation = ({
// in THIS mount marks the resume as live — a restored approval-requested tail the user answers
// after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it.
const handleApprovalResponse = useCallback(
(args: {id: string; approved: boolean}) => {
(args: {id: string; approved: boolean; message?: string}) => {
liveGateInteractionRef.current = {kind: "approval", id: args.id}
addToolApprovalResponse(args)
addToolApprovalResponse({id: args.id, approved: args.approved})
// Steer: a denial that carries a redirect answers the gate AND sends the instruction as a
// follow-up turn. It must be its OWN turn, not bundled into the deny-resume: resuming a
// parked gate calls `respondPermission(reject)`, which makes the harness CONTINUE the
// original prompt (run-turn.ts) — so a note fused into that resume gets subordinated to
// the original intent and ignored. As a separate turn it reliably drives the redirect.
// (The model still reasons about the bare denial first — the "flail" — because the
// harness owns the reject continuation and exposes no reject-with-feedback seam; killing
// that flail needs an upstream ACP change, not an FE one.)
const steer = args.message?.trim()
if (!args.approved && steer) submit({text: steer})
},
[addToolApprovalResponse],
[addToolApprovalResponse, submit],
)

// Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the
Expand Down Expand Up @@ -1232,11 +1243,10 @@ const AgentConversation = ({

const handleStop = useCallback(() => {
markStopped()
stop()
// Default Stop only aborts the client stream; the runner survives and keeps billing. When the
// NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION flag is set, also kill the session so the sandbox
// tears down and server-side compute halts. Kill ends the whole session (resume is #5197).
if (doesAgentChatStopKillSession() && projectId && sessionId) {
stop() // abort the client stream immediately
if (!projectId || !sessionId) return
// Opt-in hard kill (NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION): tear the whole session down.
if (doesAgentChatStopKillSession()) {
killSession({sessionId, projectId})
.then((ok) => {
if (ok) {
Expand All @@ -1247,7 +1257,13 @@ const AgentConversation = ({
}
})
.catch(() => {})
return
}
// Default Stop: cooperatively cancel the CURRENT TURN. The control-plane `cancel` command
// (no inputs, no force) drops the alive lock; the runner closes the turn as interrupted and
// the session STAYS OPEN so a follow-up prompt resumes it — instead of the old behaviour where
// the client stream aborted but the runner kept running and billing.
commandSessionStream({sessionId, projectId}).catch(() => {})
}, [markStopped, stop, projectId, sessionId, queryClient])

// ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ──
Expand Down
10 changes: 10 additions & 0 deletions web/oss/src/components/AgentChatSlice/assets/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ export const isAgentChatSliceEnabled = (): boolean =>
*/
export const doesAgentChatStopKillSession = (): boolean =>
(getEnv("NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION") || "").toLowerCase() === "true"

/**
* Agent chat Steer (deny + redirect): gates the approval dock's "Redirect" control. Off by default
* (opt in with `NEXT_PUBLIC_AGENT_CHAT_STEER=true`). The UI is complete, but the redirect note runs
* as a FOLLOW-UP turn, so the model reasons about the bare denial before the note lands — the harness
* always continues the original prompt on reject and exposes no reject-with-feedback channel. Kept
* behind the flag until the runner-level "reject-and-redirect" lands (see the steer proposal, #5444).
*/
export const isAgentChatSteerEnabled = (): boolean =>
(getEnv("NEXT_PUBLIC_AGENT_CHAT_STEER") || "").toLowerCase() === "true"
Loading
Loading