Files
oh-my-pi/packages/coding-agent/src/eval/js/executor.ts
T
can1357 76ca917bfd fix(coding-agent-eval): suspended idle timeout during delegated bridge calls
- 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.
2026-06-06 14:02:04 +02:00

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,
};
}
}