76ca917bfd
- Added timeout pause/resume control ops and helper to suspend idle timers during bridge work. - Fixed TimeoutError on long delegated agent()/llm() calls by pausing timeout while silent. - Updated IdleTimeout with reference-counted pauses, ignored checks while paused, and resumed fresh. - Replaced heartbeat keepalives with timeout-control events in bridge paths and status routing.
167 lines
5.4 KiB
TypeScript
167 lines
5.4 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;
|
|
reset?: boolean;
|
|
sessionFile?: string;
|
|
artifactPath?: string;
|
|
artifactId?: string;
|
|
session: ToolSession;
|
|
}
|
|
|
|
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 {
|
|
if (timeoutMs === undefined) return "Command timed out";
|
|
const secs = Math.max(1, Math.round(timeoutMs / 1000));
|
|
return `Command timed out after ${secs} seconds`;
|
|
}
|
|
|
|
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,
|
|
cwd: options.cwd ?? options.session.cwd,
|
|
session: options.session,
|
|
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,
|
|
};
|
|
}
|
|
}
|