Skip to content

Commit 2c90d37

Browse files
committed
fix(runner): applied-state identity and safe teardown (lifecycle steps 1-2)
The environment now owns its applied state; park and repark no longer accept request-derived fingerprints, so the approval-stale-config bug is unrepresentable. Four named teardown reasons with a parkable allowlist: a config change stops the sandbox instead of deleting it, and true incompatibility still destroys with the reconnect pointer cleared. Revision id, version, and draft flag leave the fingerprint, so a commit with identical content keeps the warm session.
1 parent e2166f3 commit 2c90d37

14 files changed

Lines changed: 1103 additions & 47 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* `AppliedEnvironmentState` — what an environment ACTUALLY has installed.
3+
*
4+
* LIFECYCLE MIGRATION, STEP 2. This is the smallest change that kills the stale-config bug class.
5+
*
6+
* The bug. Until now the pool stored a `configFingerprint` its CALLER supplied, and the caller
7+
* supplied the INCOMING request's fingerprint. On the ordinary path that is harmless, because the
8+
* dispatch already proved the incoming and parked configurations equal. On the approval-resume
9+
* path it is not: that branch never compares configurations, so the re-park stamped a
10+
* configuration the environment had never applied. The next turn then read that stamp, found a
11+
* match, and continued warm on an environment running something else.
12+
*
13+
* The fix is structural, not a patch. A request says what somebody WANTED. Only the environment
14+
* knows what it GOT. So the environment owns its applied state, and the pool reads it. There is
15+
* no fingerprint parameter left for a caller to stamp, which makes the bug unrepresentable rather
16+
* than merely fixed.
17+
*
18+
* Scope note. This slice carries one facet, `configFingerprint`, because that is the facet the
19+
* pool compares today. The richer shape in the lifecycle design (sandbox, runtime, mounts,
20+
* workspace, and harness-session facets, each with its own generation) arrives with the
21+
* reconciliation router. `generation` is here from the start so a later facet split has a counter
22+
* to build on.
23+
*/
24+
25+
/**
26+
* The state an environment has successfully installed. Read-only to everyone except
27+
* `commitApplied`.
28+
*/
29+
export interface AppliedEnvironmentState {
30+
/**
31+
* Increments on every successful commit. It is a monotonic counter for logs and for tests, and
32+
* it never re-enters environment identity. A test asserts that two commits of the same
33+
* fingerprint still advance it, so "nothing changed" and "we re-applied" stay distinguishable.
34+
*/
35+
readonly generation: number;
36+
/**
37+
* The canonical hash of the configuration this environment actually runs. It is stamped from a
38+
* SUCCESSFUL acquire, never from an incoming request.
39+
*/
40+
readonly configFingerprint: string;
41+
}
42+
43+
/**
44+
* The structural contract the pool needs. It is deliberately minimal: the pool must stay
45+
* engine-agnostic, so it constrains its environment type to this shape rather than importing the
46+
* engine.
47+
*/
48+
export interface AppliedStateOwner {
49+
readonly appliedState: AppliedEnvironmentState;
50+
}
51+
52+
/**
53+
* A mutable holder for one environment's applied state.
54+
*
55+
* `commitApplied` is the ONLY way to advance it. Every caller must be a lifecycle action that
56+
* already succeeded. Committing before the action succeeds recreates the bug this module exists
57+
* to remove.
58+
*/
59+
export class AppliedState implements AppliedStateOwner {
60+
#generation: number;
61+
#configFingerprint: string;
62+
63+
constructor(configFingerprint: string) {
64+
this.#generation = 1;
65+
this.#configFingerprint = configFingerprint;
66+
}
67+
68+
get appliedState(): AppliedEnvironmentState {
69+
// A fresh object each read, so a caller cannot hold a reference and mutate it later.
70+
return {
71+
generation: this.#generation,
72+
configFingerprint: this.#configFingerprint,
73+
};
74+
}
75+
76+
/**
77+
* Record a lifecycle action that ALREADY SUCCEEDED.
78+
*
79+
* Call this after the action, never before and never instead of it. A `setModel` that threw, a
80+
* workspace refresh that failed halfway, or a session reopen that lost its native history must
81+
* leave applied state exactly where it was. That is the partial-reconciliation rule from the
82+
* lifecycle design, and it is why this method takes a result rather than a desired value.
83+
*/
84+
commitApplied(result: { configFingerprint: string }): void {
85+
this.#generation += 1;
86+
this.#configFingerprint = result.configFingerprint;
87+
}
88+
}

services/runner/src/engines/sandbox_agent/environment-setup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { rmSync } from "node:fs";
22

33
import { apiBase } from "../../apiBase.ts";
4+
import { AppliedState } from "./applied-state.ts";
45

56
import { resolveRunSessionId, type AgentRunRequest } from "../../protocol.ts";
67
import { type ClientToolOutcome } from "../../responder.ts";
@@ -32,6 +33,7 @@ import {
3233
type PiModelConfigPlan,
3334
} from "./pi-model-config.ts";
3435
import { buildRunPlan } from "./run-plan.ts";
36+
import { configFingerprint } from "./session-identity.ts";
3537
import type {
3638
SandboxAgentDeps,
3739
SessionEnvironment,
@@ -368,7 +370,16 @@ export async function prepareEnvironmentSetup(
368370
// so its handler is torn down deterministically and cannot write a result after the turn ends.
369371
const mcpAbort = new AbortController();
370372

373+
// LIFECYCLE MIGRATION, STEP 2. The environment owns what it applied. It is seeded from the
374+
// request that is building it, because that request IS what this environment installs. Every
375+
// later change must go through `commitApplied`, and only after the change succeeds.
376+
const applied = new AppliedState(configFingerprint(request));
377+
371378
const environment: SessionEnvironment = {
379+
get appliedState() {
380+
return applied.appliedState;
381+
},
382+
commitApplied: (result) => applied.commitApplied(result),
372383
plan,
373384
logger,
374385
deps,

services/runner/src/engines/sandbox_agent/environment.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ import {
108108
routeSessionEventToActiveTurn,
109109
} from "./session-events.ts";
110110
import { buildSandboxProvider } from "./provider.ts";
111-
import { readStoredSandboxPointer } from "./sandbox-reconnect.ts";
111+
import {
112+
markSandboxDestroyed,
113+
readStoredSandboxPointer,
114+
} from "./sandbox-reconnect.ts";
112115
import type {
113116
AcquireEnvironmentResult,
114117
SandboxAgentDeps,
@@ -322,7 +325,15 @@ export async function acquireEnvironment(
322325
);
323326
}
324327
}
325-
if (!parked) await environment.sandbox?.destroySandbox().catch(() => {});
328+
if (!parked) {
329+
// Record the id BEFORE the delete call, and record it even when the call throws. A delete
330+
// that failed may still have removed the sandbox, so reconnecting to it is a wasted round
331+
// trip either way. See `markSandboxDestroyed`.
332+
markSandboxDestroyed(
333+
environment.sandbox?.sandboxId ?? plan.sandboxId ?? undefined,
334+
);
335+
await environment.sandbox?.destroySandbox().catch(() => {});
336+
}
326337
await environment.sandbox?.dispose().catch(() => {});
327338
// Unmount the durable cwd BEFORE removing the dir: data lives in the store, only the host
328339
// mountpoint is torn down. If unmount is not CONFIRMED gone, skip the delete: rmSync must

services/runner/src/engines/sandbox_agent/runtime-contracts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "./session-continuity-durable.ts";
4343
import { type SessionContinuityStore } from "./session-continuity.ts";
4444
import { type InstalledMountExpiries } from "./session-identity.ts";
45+
import type { AppliedEnvironmentState } from "./applied-state.ts";
4546
import { type TeardownReason } from "./teardown.ts";
4647
import { uploadToolMcpAssets } from "./tool-mcp-assets.ts";
4748
import { prepareWorkspace } from "./workspace.ts";
@@ -217,6 +218,17 @@ export function sendLastMessageOnly(opts: RunTurnOptions): boolean {
217218
* call. Per-turn state rides `currentTurn`, swapped in by `runTurn`.
218219
*/
219220
export interface SessionEnvironment {
221+
/**
222+
* What this environment ACTUALLY has installed.
223+
*
224+
* LIFECYCLE MIGRATION, STEP 2. The pool reads this instead of a fingerprint its caller supplies,
225+
* so a request can no longer stamp a configuration the environment never applied. Only
226+
* `commitApplied` advances it, and only after a lifecycle action succeeds. See
227+
* `applied-state.ts`.
228+
*/
229+
readonly appliedState: AppliedEnvironmentState;
230+
/** Record a lifecycle action that already succeeded. The only writer of `appliedState`. */
231+
commitApplied: (result: { configFingerprint: string }) => void;
220232
plan: RunPlan;
221233
logger: Log;
222234
deps: SandboxAgentDeps;

services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,44 @@
1212
*/
1313
import { fetchLatestSessionTurn } from "./session-continuity-durable.ts";
1414

15+
/**
16+
* Sandbox ids this runner process has DELETED.
17+
*
18+
* LIFECYCLE MIGRATION, STEP 1. The stored pointer is the latest turn's `sandbox_id`, and the turns
19+
* table is append-only, so there is no pointer row to clear when a destroy deletes the sandbox.
20+
* The dead id therefore stays readable until the next turn appends its own row. Reconnecting to it
21+
* fails and falls through to a fresh create, which is safe but spends a provider round trip on a
22+
* sandbox we ourselves deleted moments earlier.
23+
*
24+
* This set closes that window inside one runner process: `markSandboxDestroyed` records the id at
25+
* the moment of deletion, and `readStoredSandboxPointer` refuses to hand it back.
26+
*
27+
* What it deliberately does NOT do: it is per-process and in-memory, so another replica, or this
28+
* replica after a restart, still reads the stale id and still falls through to a fresh create.
29+
* That path was always correct and stays correct. This is a latency fix with a correctness-shaped
30+
* name, and treating it as a durable guarantee would be wrong.
31+
*/
32+
const destroyedSandboxIds = new Set<string>();
33+
34+
/** Cap the set so a long-lived replica cannot grow it without bound. */
35+
const DESTROYED_SANDBOX_ID_MAX = 512;
36+
37+
/** Record that this process deleted `sandboxId`, so it never reconnects to it. */
38+
export function markSandboxDestroyed(sandboxId: string | undefined): void {
39+
if (!sandboxId) return;
40+
if (destroyedSandboxIds.size >= DESTROYED_SANDBOX_ID_MAX) {
41+
// Drop the oldest entry. Losing one only costs the failed-reconnect round trip it saved.
42+
const oldest = destroyedSandboxIds.values().next().value;
43+
if (oldest !== undefined) destroyedSandboxIds.delete(oldest);
44+
}
45+
destroyedSandboxIds.add(sandboxId);
46+
}
47+
48+
/** Test seam: forget every recorded id. */
49+
export function resetDestroyedSandboxIds(): void {
50+
destroyedSandboxIds.clear();
51+
}
52+
1553
export interface SandboxPointerDeps {
1654
apiBase?: string;
1755
authorization: string;
@@ -35,5 +73,11 @@ export async function readStoredSandboxPointer(
3573
const latest = await fetchLatestSessionTurn(sessionId, undefined, deps);
3674
const id = latest?.sandbox_id;
3775
if (typeof id !== "string" || id.length === 0) return undefined;
76+
if (destroyedSandboxIds.has(id)) {
77+
// This process deleted that sandbox. Reconnecting would fail, so skip straight to a fresh
78+
// create. See `destroyedSandboxIds`.
79+
deps.log?.(`ignoring pointer to sandbox=${id} destroyed by this runner`);
80+
return undefined;
81+
}
3882
return { sandboxId: id };
3983
}

services/runner/src/engines/sandbox_agent/session-identity.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,16 @@ function canonicalJson(value: unknown): string {
195195
* every hash, the credential epoch included — each turn's relay uses the INCOMING request's
196196
* `toolCallback` (see `CredentialEpoch` and `run-turn.ts`), so the parked copy never executes
197197
* anything.
198+
*
199+
* LIFECYCLE MIGRATION, STEP 1. The workflow REVISION id, the revision version, and the draft flag
200+
* were in this hash and are now out. They are turn METADATA, not environment identity: nothing in
201+
* the sandbox, the daemon, the workspace, or the harness session changes when a revision id
202+
* changes. Keeping them here meant that committing a revision mid-conversation threw away a warm
203+
* sandbox that was still perfectly usable, which is the exact cost this project exists to remove.
204+
* They stay in `runContext` for tool binding and observability; they simply no longer decide
205+
* whether an environment may be reused.
198206
*/
199207
export function configFingerprint(request: AgentRunRequest): string {
200-
const workflow = request.runContext?.workflow;
201208
const shape = {
202209
harness: request.harness ?? null,
203210
sandbox: request.sandbox ?? null,
@@ -247,13 +254,7 @@ export function configFingerprint(request: AgentRunRequest): string {
247254
permissions: request.permissions ?? null,
248255
sandboxPermission: request.sandboxPermission ?? null,
249256
harnessFiles: request.harnessFiles ?? null,
250-
workflowRevision: workflow?.revision
251-
? {
252-
id: workflow.revision.id ?? null,
253-
version: workflow.revision.version ?? null,
254-
}
255-
: null,
256-
isDraft: workflow?.is_draft ?? null,
257+
// No `workflowRevision` and no `isDraft`. See the doc comment above.
257258
};
258259
return sha256(canonicalJson(shape));
259260
}

services/runner/src/engines/sandbox_agent/session-pool.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* never imports the engine, so it stays a pure map + timer + policy unit. Operators can disable
1313
* it explicitly with `AGENTA_RUNNER_SESSION_KEEPALIVE=off`.
1414
*/
15+
import type { AppliedStateOwner } from "./applied-state.ts";
1516
import type { CredentialEpoch, KeepaliveConfig } from "./session-identity.ts";
1617
import type { TeardownReason } from "./teardown.ts";
1718

@@ -27,10 +28,19 @@ export type SessionState = "busy" | "idle" | "awaiting_approval" | "destroyed";
2728
* One parked live session. `environment` is opaque to the pool (the engine reads it on a
2829
* continuation). `teardown` is the engine's complete, idempotent teardown closure.
2930
*/
30-
export interface LiveSession<E = unknown> {
31+
export interface LiveSession<E extends AppliedStateOwner = AppliedStateOwner> {
3132
key: string;
3233
environment: E;
33-
configFingerprint: string;
34+
/**
35+
* The configuration this session's environment ACTUALLY runs.
36+
*
37+
* LIFECYCLE MIGRATION, STEP 2. This is a READ-ONLY view of `environment.appliedState`, not a
38+
* stored copy. It used to be a field the caller wrote at park time, and the approval-resume
39+
* path wrote the INCOMING request's value into it — a configuration the environment had never
40+
* applied. Reading through to the environment removes the field a caller could stamp, so that
41+
* bug can no longer be written.
42+
*/
43+
readonly configFingerprint: string;
3444
historyFingerprint: string;
3545
/**
3646
* Whether the request this session parked from asserted a transcript beyond its own turn. A
@@ -49,11 +59,15 @@ export interface LiveSession<E = unknown> {
4959
teardownPromise?: Promise<void>;
5060
}
5161

52-
/** Fields the caller supplies to park a session (the pool arms the timer and state itself). */
53-
export interface ParkInput<E> {
62+
/**
63+
* Fields the caller supplies to park a session (the pool arms the timer and state itself).
64+
*
65+
* There is deliberately NO `configFingerprint` here. The environment owns that, and the pool
66+
* reads it. See `LiveSession.configFingerprint`.
67+
*/
68+
export interface ParkInput<E extends AppliedStateOwner> {
5469
key: string;
5570
environment: E;
56-
configFingerprint: string;
5771
historyFingerprint: string;
5872
/** See `LiveSession.historyAsserted`. Omitted defaults to the strictest resume check. */
5973
historyAsserted?: boolean;
@@ -66,7 +80,7 @@ export interface ParkInput<E> {
6680
* (Node), so check-and-set on a key needs no lock. All teardown routes through the session's
6781
* one idempotent `teardown`.
6882
*/
69-
export class SessionPool<E = unknown> {
83+
export class SessionPool<E extends AppliedStateOwner = AppliedStateOwner> {
7084
private readonly sessions = new Map<string, LiveSession<E>>();
7185

7286
constructor(
@@ -156,7 +170,6 @@ export class SessionPool<E = unknown> {
156170
async repark(
157171
session: LiveSession<E>,
158172
update: {
159-
configFingerprint: string;
160173
historyFingerprint: string;
161174
/** See `LiveSession.historyAsserted`. Omitted defaults to the strictest resume check. */
162175
historyAsserted?: boolean;
@@ -182,7 +195,8 @@ export class SessionPool<E = unknown> {
182195
this.sessions.set(session.key, session);
183196
}
184197
this.clearTimer(session);
185-
session.configFingerprint = update.configFingerprint;
198+
// No `configFingerprint` assignment. It reads through to the environment, which is the only
199+
// thing that knows what it actually applied.
186200
session.historyFingerprint = update.historyFingerprint;
187201
session.historyAsserted = update.historyAsserted ?? true;
188202
session.credentialEpoch = update.credentialEpoch;
@@ -225,10 +239,14 @@ export class SessionPool<E = unknown> {
225239
return false;
226240
}
227241

242+
const environment = input.environment;
228243
const session: LiveSession<E> = {
229244
key: input.key,
230-
environment: input.environment,
231-
configFingerprint: input.configFingerprint,
245+
environment,
246+
// A getter, not a copy: the pool always reports what the environment currently has applied.
247+
get configFingerprint() {
248+
return environment.appliedState.configFingerprint;
249+
},
232250
historyFingerprint: input.historyFingerprint,
233251
historyAsserted: input.historyAsserted ?? true,
234252
credentialEpoch: input.credentialEpoch,

0 commit comments

Comments
 (0)