ccc9b900cf
- Subagents inherit the parent's eval session id, so a child's reset: true destroyed the co-owned kernel and every sibling's interpreter state mid-session. - resolveOwnerScopedSessionKey now routes a reset from a non-exclusive owner onto a deterministic per-owner fork key: the requester gets a fresh private kernel, co-owners keep the shared one, and the fork stays sticky for that owner until its teardown reaps it. - Applied across Python, JavaScript, Ruby, and Julia executors; JS contexts gained an owner registry plus disposeVmContextsByOwner, wired into EvalRunner.disposeKernels and SDK session teardown. - Covered by pure key-resolution contracts and an end-to-end JS test: co-owner reset forks, shared state survives, fork is sticky, and per-owner dispose reaps only the fork.
179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
|
|
import type { ToolSession } from "../../tools";
|
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
|
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
|
|
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
|
|
import type { JsStatusEvent } from "./shared/types";
|
|
|
|
export interface JsExecutorOptions {
|
|
cwd?: string;
|
|
timeoutMs?: number;
|
|
deadlineMs?: number;
|
|
/**
|
|
* Runtime-work budget (ms). Used for worker cold-start headroom and
|
|
* timeout-annotation text when the caller drives cancellation via the eval
|
|
* watchdog `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
|
|
*/
|
|
idleTimeoutMs?: number;
|
|
onChunk?: (chunk: string) => Promise<void> | void;
|
|
onStatus?: (event: JsStatusEvent) => void;
|
|
signal?: AbortSignal;
|
|
sessionId: string;
|
|
/** Logical owner identifier; scopes `reset` on shared contexts and retained-worker cleanup. */
|
|
kernelOwnerId?: string;
|
|
reset?: boolean;
|
|
sessionFile?: string;
|
|
artifactPath?: string;
|
|
artifactId?: string;
|
|
session: ToolSession;
|
|
/** On-disk roots the helpers substitute for internal-URL schemes (e.g. `local://`). */
|
|
localRoots?: Record<string, string>;
|
|
}
|
|
|
|
export interface JsResult {
|
|
output: string;
|
|
exitCode: number | undefined;
|
|
cancelled: boolean;
|
|
truncated: boolean;
|
|
artifactId?: string;
|
|
totalLines: number;
|
|
totalBytes: number;
|
|
outputLines: number;
|
|
outputBytes: number;
|
|
displayOutputs: JsDisplayOutput[];
|
|
}
|
|
|
|
function getExecutionTimeoutMs(options: Pick<JsExecutorOptions, "deadlineMs" | "timeoutMs">): number | undefined {
|
|
if (options.deadlineMs !== undefined) {
|
|
return Math.max(1, options.deadlineMs - Date.now());
|
|
}
|
|
return options.timeoutMs;
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return (
|
|
(error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) ||
|
|
(error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"))
|
|
);
|
|
}
|
|
|
|
function isTimeoutReason(reason: unknown): boolean {
|
|
return (
|
|
(reason instanceof DOMException && reason.name === "TimeoutError") ||
|
|
(reason instanceof Error && reason.name === "TimeoutError")
|
|
);
|
|
}
|
|
|
|
function formatJsTimeoutAnnotation(timeoutMs: number | undefined): string {
|
|
// Timeout cancellation force-kills the worker (the only way to interrupt
|
|
// synchronous user code), which discards the persistent VM state. Say so,
|
|
// or the model will keep referencing variables that no longer exist.
|
|
const reset = "The JS worker was force-killed and its VM state was reset; variables from earlier cells are gone.";
|
|
if (timeoutMs === undefined) return `Command timed out. ${reset}`;
|
|
const secs = Math.max(1, Math.round(timeoutMs / 1000));
|
|
return `Command timed out after ${secs} seconds. ${reset}`;
|
|
}
|
|
|
|
export async function executeJs(code: string, options: JsExecutorOptions): Promise<JsResult> {
|
|
const displayOutputs: JsDisplayOutput[] = [];
|
|
const outputSink = new OutputSink({
|
|
artifactPath: options.artifactPath,
|
|
artifactId: options.artifactId,
|
|
spillThreshold: DEFAULT_MAX_BYTES,
|
|
headBytes: resolveOutputSinkHeadBytes(options.session.settings),
|
|
maxColumns: resolveOutputMaxColumns(options.session.settings),
|
|
onChunk: chunk => options.onChunk?.(chunk),
|
|
});
|
|
const legacyTimeoutMs = getExecutionTimeoutMs(options);
|
|
const timeoutSignal =
|
|
typeof legacyTimeoutMs === "number" && Number.isFinite(legacyTimeoutMs) && legacyTimeoutMs > 0
|
|
? AbortSignal.timeout(legacyTimeoutMs)
|
|
: undefined;
|
|
const signal =
|
|
options.signal && timeoutSignal
|
|
? AbortSignal.any([options.signal, timeoutSignal])
|
|
: (options.signal ?? timeoutSignal);
|
|
// The eval tool drives cancellation via its own watchdog `signal` and passes
|
|
// only the runtime-work budget; use it solely as worker cold-start headroom
|
|
// and never derive a competing fixed timer from it.
|
|
const acquireBudgetMs = legacyTimeoutMs ?? options.idleTimeoutMs;
|
|
|
|
try {
|
|
await executeInVmContext({
|
|
sessionKey: options.sessionId,
|
|
sessionId: options.sessionId,
|
|
ownerId: options.kernelOwnerId,
|
|
cwd: options.cwd ?? options.session.cwd,
|
|
session: options.session,
|
|
localRoots: options.localRoots,
|
|
reset: options.reset,
|
|
code,
|
|
filename: `js-cell-${crypto.randomUUID()}.js`,
|
|
timeoutMs: acquireBudgetMs,
|
|
runState: {
|
|
signal,
|
|
onText: chunk => outputSink.push(chunk),
|
|
onDisplay: output => {
|
|
if (output.type === "status") {
|
|
// Timeout-control events drive the eval watchdog only; never
|
|
// store or render them as cell output.
|
|
options.onStatus?.(output.event);
|
|
if (isEvalTimeoutControlEvent(output.event)) return;
|
|
}
|
|
displayOutputs.push(output);
|
|
},
|
|
},
|
|
});
|
|
const summary = await outputSink.dump();
|
|
return {
|
|
output: summary.output,
|
|
exitCode: 0,
|
|
cancelled: false,
|
|
truncated: summary.truncated,
|
|
artifactId: summary.artifactId,
|
|
totalLines: summary.totalLines,
|
|
totalBytes: summary.totalBytes,
|
|
outputLines: summary.outputLines,
|
|
outputBytes: summary.outputBytes,
|
|
displayOutputs,
|
|
};
|
|
} catch (error) {
|
|
if (signal?.aborted || isAbortError(error)) {
|
|
const timedOut = Boolean(timeoutSignal?.aborted) || isTimeoutReason(options.signal?.reason);
|
|
if (timedOut) {
|
|
outputSink.push(formatJsTimeoutAnnotation(legacyTimeoutMs ?? options.idleTimeoutMs));
|
|
}
|
|
const summary = await outputSink.dump();
|
|
return {
|
|
output: summary.output,
|
|
exitCode: undefined,
|
|
cancelled: true,
|
|
truncated: summary.truncated,
|
|
artifactId: summary.artifactId,
|
|
totalLines: summary.totalLines,
|
|
totalBytes: summary.totalBytes,
|
|
outputLines: summary.outputLines,
|
|
outputBytes: summary.outputBytes,
|
|
displayOutputs,
|
|
};
|
|
}
|
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
outputSink.push(message);
|
|
const summary = await outputSink.dump();
|
|
return {
|
|
output: summary.output,
|
|
exitCode: 1,
|
|
cancelled: false,
|
|
truncated: summary.truncated,
|
|
artifactId: summary.artifactId,
|
|
totalLines: summary.totalLines,
|
|
totalBytes: summary.totalBytes,
|
|
outputLines: summary.outputLines,
|
|
outputBytes: summary.outputBytes,
|
|
displayOutputs,
|
|
};
|
|
} finally {
|
|
await outputSink.dispose();
|
|
}
|
|
}
|