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
544 changes: 544 additions & 0 deletions docs/plans/early-sandbox-single-websocket.md

Large diffs are not rendered by default.

45 changes: 33 additions & 12 deletions packages/control-plane/src/sandbox/lifecycle/decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ describe("evaluateSpawnDecision", () => {
expect(decision.action).toBe("skip");
});

it('returns "spawn" when stuck in "spawning" past the spawning timeout (recovers interrupted spawn)', () => {
it('returns "spawn" when stuck in "spawning" past the spawning timeout', () => {
const now = Date.now();
const state: SandboxState = {
status: "spawning",
Expand Down Expand Up @@ -847,7 +847,7 @@ describe("evaluateConnectingTimeout", () => {

it("returns not timed out for non-connecting status", () => {
const now = Date.now();
const result = evaluateConnectingTimeout("ready", now - 200_000, config, now);
const result = evaluateConnectingTimeout("ready", now - 200_000, null, config, now);

expect(result.isTimedOut).toBe(false);
expect(result.elapsedMs).toBe(0);
Expand All @@ -857,7 +857,7 @@ describe("evaluateConnectingTimeout", () => {
const now = Date.now();
const createdAt = now - 60_000; // 60s ago, well within 120s timeout

const result = evaluateConnectingTimeout("connecting", createdAt, config, now);
const result = evaluateConnectingTimeout("connecting", createdAt, null, config, now);

expect(result.isTimedOut).toBe(false);
expect(result.elapsedMs).toBe(60_000);
Expand All @@ -867,7 +867,7 @@ describe("evaluateConnectingTimeout", () => {
const now = Date.now();
const createdAt = now - 130_000; // 130s ago, past 120s timeout

const result = evaluateConnectingTimeout("connecting", createdAt, config, now);
const result = evaluateConnectingTimeout("connecting", createdAt, null, config, now);

expect(result.isTimedOut).toBe(true);
expect(result.elapsedMs).toBe(130_000);
Expand All @@ -877,35 +877,56 @@ describe("evaluateConnectingTimeout", () => {
const now = Date.now();
const createdAt = now - config.timeoutMs; // Exactly at timeout

const result = evaluateConnectingTimeout("connecting", createdAt, config, now);
const result = evaluateConnectingTimeout("connecting", createdAt, null, config, now);

expect(result.isTimedOut).toBe(true);
expect(result.elapsedMs).toBe(config.timeoutMs);
});

it("returns timed out when stuck in spawning past timeout (interrupted spawn)", () => {
it("uses the existing timeout when no heartbeat has arrived", () => {
const now = Date.now();
const createdAt = now - 130_000; // 130s ago, past 120s timeout
const createdAt = now - config.timeoutMs;

const result = evaluateConnectingTimeout("spawning", createdAt, config, now);
const result = evaluateConnectingTimeout("spawning", createdAt, null, config, now);

expect(result.isTimedOut).toBe(true);
expect(result.elapsedMs).toBe(130_000);
expect(result.livenessAt).toBe(createdAt);
});

it("returns not timed out for spawning within timeout window", () => {
it("extends boot indefinitely while authenticated heartbeats remain recent", () => {
const now = Date.now();
const result = evaluateConnectingTimeout("spawning", now - 60_000, config, now);
const createdAt = now - 60 * 60_000;
const lastHeartbeat = now - 30_000;

const result = evaluateConnectingTimeout("connecting", createdAt, lastHeartbeat, config, now);

expect(result.isTimedOut).toBe(false);
expect(result.livenessAt).toBe(lastHeartbeat);
expect(result.deadlineAt).toBe(lastHeartbeat + config.timeoutMs);
});

it("times out when boot heartbeats become stale", () => {
const now = Date.now();
const lastHeartbeat = now - config.timeoutMs;

const result = evaluateConnectingTimeout(
"connecting",
now - 600_000,
lastHeartbeat,
config,
now
);

expect(result.isTimedOut).toBe(true);
expect(result.livenessAt).toBe(lastHeartbeat);
});

it("ignores all non-spawning/connecting statuses", () => {
const now = Date.now();
const old = now - 999_999;

for (const status of ["pending", "ready", "stopped", "failed", "stale"] as const) {
const result = evaluateConnectingTimeout(status, old, config, now);
const result = evaluateConnectingTimeout(status, old, null, config, now);
expect(result.isTimedOut).toBe(false);
}
});
Expand Down
52 changes: 29 additions & 23 deletions packages/control-plane/src/sandbox/lifecycle/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export interface SandboxState {
status: SandboxStatus;
/** When the sandbox was created/spawned */
createdAt: number;
/** Last server-received runtime liveness report during startup. */
lastHeartbeat?: number | null;
/** Provider object ID if the sandbox exists remotely */
providerObjectId?: string | null;
/** Snapshot image ID if available for restore */
Expand All @@ -168,16 +170,7 @@ export interface SpawnConfig {
cooldownMs: number;
/** Time to wait for WebSocket after spawn (default: 60s) */
readyWaitMs: number;
/**
* Max time a sandbox may remain in "spawning"/"connecting" before it is
* treated as dead and a fresh spawn is allowed (default: 120s).
*
* Guards against spawns interrupted before the sandbox connects (provider
* crash, redeploy, cancelled provider call). Such a spawn can leave the
* persisted status pinned at "spawning"/"connecting" indefinitely — the
* connecting-timeout alarm may never have been scheduled — which otherwise
* makes every later spawn attempt skip with "already spawning" forever.
*/
/** Max time without startup liveness before a fresh spawn is allowed. */
spawningTimeoutMs: number;
}

Expand Down Expand Up @@ -312,11 +305,17 @@ export function evaluateSpawnDecision(
// "connecting" forever — the connecting-timeout alarm may never have been
// scheduled. Treat a stale spawn/connect as dead so a fresh spawn can recover
// the session, instead of skipping indefinitely.
if (
(state.status === "spawning" || state.status === "connecting") &&
timeSinceLastSpawn < config.spawningTimeoutMs
) {
return { action: "skip", reason: `already ${state.status}` };
if (state.status === "spawning" || state.status === "connecting") {
const startup = evaluateConnectingTimeout(
state.status,
state.createdAt,
state.lastHeartbeat ?? null,
{ timeoutMs: config.spawningTimeoutMs },
now
);
if (!startup.isTimedOut) {
return { action: "skip", reason: `already ${state.status}` };
}
}

// Don't spawn if status is "ready" and we have an active WebSocket
Expand Down Expand Up @@ -542,14 +541,12 @@ export function evaluateHeartbeatHealth(
* Configuration for the initial-connect watchdog.
*/
export interface ConnectingTimeoutConfig {
/** Maximum time in ms a sandbox can stay in "connecting" before being failed */
/** Maximum gap without startup liveness. */
timeoutMs: number;
}

/**
* Default connecting timeout: 2 minutes.
* Boot sequence (git clone → setup.sh → start.sh → opencode → bridge connect) typically
* takes 30–90 seconds. Two minutes provides margin without leaving users waiting too long.
* Default startup liveness timeout: 2 minutes.
*/
export const DEFAULT_CONNECTING_TIMEOUT_CONFIG: ConnectingTimeoutConfig = {
timeoutMs: 120_000,
Expand All @@ -561,8 +558,12 @@ export const DEFAULT_CONNECTING_TIMEOUT_CONFIG: ConnectingTimeoutConfig = {
export interface ConnectingTimeoutResult {
/** Whether the sandbox has exceeded the connecting timeout */
isTimedOut: boolean;
/** Time elapsed since sandbox was created (ms) */
/** Time elapsed since the latest startup liveness signal. */
elapsedMs: number;
/** Timestamp from which the active timeout window is measured. */
livenessAt: number;
/** Next timestamp at which startup should be evaluated. */
deadlineAt: number;
}

/**
Expand All @@ -582,24 +583,29 @@ export interface ConnectingTimeoutResult {
*
* @param status - Current sandbox status
* @param createdAt - Timestamp (ms) when the sandbox was spawned
* @param lastHeartbeat - Latest authenticated startup heartbeat receipt time
* @param config - Connecting timeout configuration
* @param now - Current timestamp (ms)
* @returns Whether the sandbox has timed out and how long it's been spawning/connecting
* @returns Current timeout state and deadline
*/
export function evaluateConnectingTimeout(
status: SandboxStatus,
createdAt: number,
lastHeartbeat: number | null,
config: ConnectingTimeoutConfig,
now: number
): ConnectingTimeoutResult {
if (status !== "connecting" && status !== "spawning") {
return { isTimedOut: false, elapsedMs: 0 };
return { isTimedOut: false, elapsedMs: 0, livenessAt: createdAt, deadlineAt: createdAt };
}

const elapsedMs = now - createdAt;
const livenessAt = Math.max(createdAt, lastHeartbeat ?? 0);
const elapsedMs = now - livenessAt;
return {
isTimedOut: elapsedMs >= config.timeoutMs,
elapsedMs,
livenessAt,
deadlineAt: livenessAt + config.timeoutMs,
};
}

Expand Down
Loading
Loading