Skip to content

Commit 9196919

Browse files
authored
Merge pull request #381 from code-yeongyu/fix/eval-timeout-state-aware
fix(codemode): tell the truth about eval kernel state on interrupt
2 parents 3907d42 + a50a2ca commit 9196919

22 files changed

Lines changed: 569 additions & 42 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { startBridgeServer } from "../src/bridge/http-server.ts";
2+
import { createInterpreterDetector } from "../src/interpreters/detect.ts";
3+
import { PythonKernel } from "../src/kernels/py/kernel.ts";
4+
import { createEvalTool } from "../src/tool/eval-tool.ts";
5+
import type { ExtensionContext } from "@code-yeongyu/senpi";
6+
7+
class QaScenarioError extends Error {
8+
readonly name = "QaScenarioError";
9+
}
10+
11+
const detected = await createInterpreterDetector().detect("py");
12+
if (!detected.ok) throw new QaScenarioError("no python interpreter");
13+
const server = await startBridgeServer({
14+
onCall: async () => {
15+
throw new QaScenarioError("no bridge tools in qa");
16+
},
17+
onEmit: async () => {},
18+
onCompletion: async () => {
19+
throw new QaScenarioError("no completion in qa");
20+
},
21+
});
22+
try {
23+
const kernel = await PythonKernel.start({
24+
interpreterPath: detected.path,
25+
sessionId: "qa-timeout-state-" + crypto.randomUUID(),
26+
cwd: process.cwd(),
27+
connection: { port: server.port, token: server.token },
28+
});
29+
try {
30+
const tool = createEvalTool({
31+
enabledLanguages: { js: false, py: true, rb: false, jl: false },
32+
kernelManager: { getKernel: async () => kernel },
33+
cellTimeoutSeconds: 1,
34+
executeTool: (async () => {
35+
throw new QaScenarioError("no executeTool in qa");
36+
}) as never,
37+
});
38+
const ctx = { mode: "print" } as unknown as ExtensionContext;
39+
40+
// Cooperative interrupt: the runner answers SIGINT, so state must survive and
41+
// the timeout message must say the kernel remains running.
42+
const cooperative = await tool
43+
.execute("qa-coop", { language: "py", code: "x=42\nimport time\ntime.sleep(30)", on_timeout: "error", timeout: 1 }, undefined, undefined, ctx)
44+
.then(() => "UNEXPECTED-SUCCESS", (error: Error) => `${error.name}: ${error.message}`);
45+
console.log(`COOPERATIVE_TIMEOUT: ${cooperative}`);
46+
47+
// Assert on the kernel's own readback so output-capture formatting cannot
48+
// mask whether the interrupted kernel actually kept its variables.
49+
const readback = await kernel.run({ cellId: "qa-readback", code: "x", timeoutMs: 5_000 });
50+
console.log(`STATE_READBACK: ${JSON.stringify(readback)}`);
51+
52+
if (!cooperative.includes("TimeoutError")) throw new QaScenarioError(`expected TimeoutError: ${cooperative}`);
53+
if (!/remains running|preserved/i.test(cooperative))
54+
throw new QaScenarioError(`timeout message did not name preserved state: ${cooperative}`);
55+
if (!readback.ok || readback.valueRepr !== "42")
56+
throw new QaScenarioError(`python state did not survive timeout: ${JSON.stringify(readback)}`);
57+
} finally {
58+
await kernel.close();
59+
}
60+
} finally {
61+
await server.close();
62+
}

packages/senpi-codemode/src/kernels/js/context-manager.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2+
import type { KernelInterruptHandle } from "../../tool/types.ts";
23
import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
34
import {
45
assertJavaScriptKernelOpen,
@@ -48,14 +49,16 @@ export class JavaScriptKernel {
4849
return await promise;
4950
}
5051

51-
async interrupt(reason = "interrupted"): Promise<void> {
52+
async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
5253
assertJavaScriptKernelOpen(this.#lifecycle, "interrupt");
5354
const active = this.#runs.active;
5455
const target = this.#runs.takeInterruptTarget();
55-
if (!target) return;
56+
if (!target) return { stateRetained: Promise.resolve(true) };
5657
if (target === active) this.#clearTimeout();
5758
this.#runs.settle(target, stoppedResult(target.input.cellId, `JS cell interrupted: ${reason}`));
5859
await this.#restartAfterStop();
60+
// A restart always replaces the worker VM, so no user global survives.
61+
return { stateRetained: Promise.resolve(false) };
5962
}
6063

6164
async reset(): Promise<void> {

packages/senpi-codemode/src/kernels/py/kernel-contract.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@ export interface PendingRun {
2929
timeoutTimer: NodeJS.Timeout | null;
3030
escalationTimer?: NodeJS.Timeout;
3131
interruptReason?: string;
32+
/** Set while an interrupt outcome is pending; resolved once the kernel knows whether state survived. */
33+
resolveStateRetained?: (retained: boolean) => void;
3234
}

packages/senpi-codemode/src/kernels/py/kernel.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { KernelInterruptHandle } from "../../tool/types.ts";
12
import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
23
import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
34

@@ -41,23 +42,28 @@ export class PythonKernel {
4142
});
4243
}
4344

44-
async interrupt(reason = "interrupted"): Promise<void> {
45+
async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
4546
if (this.#failure) throw this.#failure;
4647
for (const pending of [...this.#queue]) {
4748
pending.interruptReason = reason;
4849
this.#settleRun(pending, failedPythonResult(pending.input.cellId, "Eval interrupted"));
4950
}
5051
const active = this.#active;
5152
const transport = this.#transport;
52-
if (!active || !transport || active.interruptReason !== undefined) return;
53+
if (!active || !transport || active.interruptReason !== undefined)
54+
return { stateRetained: Promise.resolve(true) };
5355
active.interruptReason = reason;
5456
if (active.timeoutTimer) clearTimeout(active.timeoutTimer);
5557
active.timeoutTimer = null;
5658
active.escalationTimer = setTimeout(
5759
() => void this.#escalateInterruptedRun(active).catch(() => undefined),
5860
interruptEscalationMs,
5961
);
62+
const stateRetained = new Promise<boolean>((resolve) => {
63+
active.resolveStateRetained = resolve;
64+
});
6065
transport.interrupt(reason);
66+
return { stateRetained };
6167
}
6268

6369
async reset(): Promise<void> {
@@ -187,13 +193,18 @@ export class PythonKernel {
187193
if (this.#transport !== transport) return;
188194
const pending = this.#pending.get(result.cellId);
189195
if (pending) this.#settleRun(pending, result);
196+
// A result frame from the live runner proves the process survived the interrupt.
197+
if (pending?.resolveStateRetained) pending.resolveStateRetained(true);
190198
}
191199

192200
#onExit(transport: PythonKernelTransport, error: Error): void {
193201
if (this.#transport !== transport) return;
194202
this.#transport = null;
195203
const active = this.#active;
196-
if (active) this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
204+
if (active) {
205+
if (active.resolveStateRetained) active.resolveStateRetained(false);
206+
this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
207+
}
197208
this.#startNext();
198209
}
199210

@@ -209,7 +220,10 @@ export class PythonKernel {
209220
},
210221
);
211222
const active = this.#active;
212-
if (active) this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
223+
if (active) {
224+
if (active.resolveStateRetained) active.resolveStateRetained(false);
225+
this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
226+
}
213227
}
214228

215229
#settleRun(pending: PendingRun, result: ResultMessage): void {
@@ -258,6 +272,7 @@ export class PythonKernel {
258272
async #escalateInterruptedRun(pending: PendingRun): Promise<void> {
259273
if (this.#active !== pending || pending.interruptReason === undefined) return;
260274
const transport = this.#transport;
275+
if (pending.resolveStateRetained) pending.resolveStateRetained(false);
261276
if (transport) await this.#beginRetirement(transport);
262277
if (this.#pending.has(pending.input.cellId))
263278
this.#settleRun(pending, failedPythonResult(pending.input.cellId, "Eval interrupted"));

packages/senpi-codemode/src/kernels/py/prelude.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import locale
1313
import os
1414
import re
15+
import signal
1516
import subprocess
1617
import sys
1718
import time
@@ -886,6 +887,9 @@ def run_cell(cell_id: str, code: str) -> None:
886887
start = time.monotonic()
887888
stdout = io.StringIO()
888889
stderr = io.StringIO()
890+
# SIGINT must interrupt user code here; the idle baseline (set between
891+
# cells) ignores it so a late signal cannot kill the stdin-read loop.
892+
signal.signal(signal.SIGINT, signal.default_int_handler)
889893
try:
890894
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
891895
body, expression = compile_cell(code)
@@ -914,6 +918,8 @@ def run_cell(cell_id: str, code: str) -> None:
914918
"durationMs": elapsed(start),
915919
}
916920
)
921+
finally:
922+
signal.signal(signal.SIGINT, signal.SIG_IGN)
917923

918924

919925
def elapsed(start: float) -> int:
@@ -942,6 +948,7 @@ def handle(message: dict[str, Any]) -> bool:
942948

943949

944950
def main() -> None:
951+
signal.signal(signal.SIGINT, signal.SIG_IGN)
945952
for raw in sys.stdin:
946953
try:
947954
if not handle(json.loads(raw)):

packages/senpi-codemode/src/kernels/shared/subprocess-kernel.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
22
import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3+
import type { KernelInterruptHandle } from "../../tool/types.ts";
34
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
45
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
56
import { SubprocessRunQueue } from "./subprocess-queue.ts";
@@ -44,24 +45,26 @@ export class SubprocessKernel {
4445
return run;
4546
}
4647

47-
async interrupt(reason = "interrupted"): Promise<void> {
48-
if (this.closed) return;
48+
async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
49+
if (this.closed) return { stateRetained: Promise.resolve(true) };
4950
if (!this.runs.active) {
50-
if (!this.retirementPromise) return;
51+
if (!this.retirementPromise) return { stateRetained: Promise.resolve(true) };
5152
const queued = this.runs.takeWaiting();
5253
if (queued) this.runs.settle(queued, failureResult(queued, new CellInterruptedError(reason)));
53-
return;
54+
return { stateRetained: Promise.resolve(true) };
5455
}
5556
const process = this.process;
5657
process?.retire();
5758
this.runs.clearToolCalls();
5859
const run = this.runs.active;
59-
if (!run) return;
60+
if (!run) return { stateRetained: Promise.resolve(true) };
6061
this.runs.releaseActive(run);
6162
this.runs.settle(run, failureResult(run, new CellInterruptedError(reason)));
6263
const signal = globalThis.process.platform === "win32" ? "SIGTERM" : "SIGINT";
6364
await this.restartProcess(process, signal, 5_000);
6465
if (this.failure) throw this.failure;
66+
// Restart always spawns a fresh interpreter, so no user global survives.
67+
return { stateRetained: Promise.resolve(false) };
6568
}
6669

6770
nextToolCall(): Promise<ToolCallMessage> {

packages/senpi-codemode/src/prompt/eval-prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,13 @@ Fields:
128128
- \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
129129
- \`action\` (optional) — defaults to \`"run"\`. A detached cell returns its id: use \`eval({ action: "peek", cell_id })\` for buffered output/state or \`eval({ action: "stop", cell_id })\` to cancel it.
130130
131-
A detached cell keeps its language kernel busy while it finishes. Do not re-run a detached cell: the same-language busy error names its cell id and output tail; another language can continue. Completion arrives as one notification with the final value/error and buffered output. Python stop interrupts while preserving kernel state; JavaScript stop restarts a fresh worker, so its VM state is lost.
131+
A detached cell keeps its language kernel busy while it finishes. Do not re-run a detached cell: the same-language busy error names its cell id and output tail; another language can continue. Completion arrives as one notification with the final value/error and buffered output. Stopping a cell interrupts its kernel; the stop result states whether kernel state survived or the kernel was restarted and its variables lost.
132132
133133
{{#if py}}Live event loop: use top-level \`await\` directly; \`asyncio.run(…)\` raises "cannot be called from a running event loop".{{/if}}
134134
{{#if js}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}
135135
{{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
136136
{{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
137-
On error, fix and re-run only the failing step — prior calls' state survives.
137+
On error, fix and re-run only the failing step. State usually survives a normal error, but a timeout or stop may have restarted the kernel — its message says which. Before rebuilding state, check a sentinel (a variable you defined earlier); only re-establish what is actually gone, since blind re-runs duplicate side effects.
138138
</instruction>
139139
140140
<prelude>

packages/senpi-codemode/src/tool/detached-cell-manager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ type ManagedCell = {
1515
canDetach: boolean;
1616
wasDetached: boolean;
1717
kernel: EvalKernel | undefined;
18+
/** Set after an interrupt-driven stop; undefined until the kernel reports its fate. */
19+
stateRetained: boolean | undefined;
1820
outputTail: (() => string) | undefined;
1921
result: AgentToolResult<EvalToolDetails> | undefined;
2022
notificationQueued: boolean;
@@ -27,6 +29,7 @@ export interface EvalDetachedCellSnapshot {
2729
readonly state: EvalDetachedCellState;
2830
readonly outputTail: string;
2931
readonly result: AgentToolResult<EvalToolDetails> | undefined;
32+
readonly stateRetained: boolean | undefined;
3033
}
3134

3235
export interface EvalDetachedCellNotification {
@@ -78,6 +81,7 @@ export class EvalDetachedCellManager {
7881
canDetach: false,
7982
wasDetached: false,
8083
kernel: undefined,
84+
stateRetained: undefined,
8185
outputTail: undefined,
8286
result: undefined,
8387
notificationQueued: false,
@@ -116,7 +120,10 @@ export class EvalDetachedCellManager {
116120
const cell = this.#get(cellId);
117121
if (cell.state === "detached" && this.#transition(cell, "cancelled")) {
118122
const kernel = cell.kernel;
119-
if (kernel !== undefined) await kernel.interrupt(reason);
123+
if (kernel !== undefined) {
124+
const handle = await kernel.interrupt(reason);
125+
cell.stateRetained = await handle.stateRetained;
126+
}
120127
}
121128
return this.#snapshot(cell);
122129
}
@@ -220,6 +227,7 @@ export class EvalDetachedCellManager {
220227
state: cell.state,
221228
outputTail: cell.outputTail?.() ?? "",
222229
result: cell.result,
230+
stateRetained: cell.stateRetained,
223231
};
224232
}
225233
}

packages/senpi-codemode/src/tool/eval-tool.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from ".
1010
import { CellHandler, type CellState } from "./cell-handler.ts";
1111
import { EvalDetachedCellManager, type EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
1212
import type { EvalImageResizer } from "./image.ts";
13+
import { describeTimeoutState, interruptionStateNote } from "./interrupt-note.ts";
1314
import {
1415
createEvalInputSchema,
1516
type EnabledEvalLanguages,
@@ -154,6 +155,9 @@ class CellExecution {
154155
this.#abort(this.#callerSignal.reason);
155156
};
156157

158+
/** Outcome of the most recent interrupt, when a kernel was interrupted. */
159+
interruptStateRetained: Promise<boolean> | undefined;
160+
157161
#abort(reason: unknown): void {
158162
if (!this.#active) return;
159163
this.#active = false;
@@ -167,7 +171,12 @@ class CellExecution {
167171
}
168172
this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
169173
void Promise.resolve()
170-
.then(async () => await kernel.interrupt(error.message))
174+
.then(async () => {
175+
const handle = await kernel.interrupt(error.message);
176+
// Kernels predating the interrupt-outcome contract resolve void; leave
177+
// the outcome undefined so callers report an honest unknown state.
178+
this.interruptStateRetained = handle?.stateRetained;
179+
})
171180
.then(
172181
() => this.#settleAbort(error),
173182
(interruptError: unknown) => this.#settleAbort(interruptError),
@@ -361,6 +370,7 @@ async function executeCell(
361370
} catch (error) {
362371
if (handler && error instanceof Error && error.name === "CodemodeSessionDisposedError")
363372
return await handler.finalizeCancellation(error);
373+
if (error instanceof Error && error.name === "TimeoutError") throw await describeTimeoutState(error, execution);
364374
throw error;
365375
} finally {
366376
state.active = false;
@@ -445,11 +455,7 @@ function detachedResult(snapshot: EvalDetachedCellSnapshot, input: EvalToolInput
445455

446456
function snapshotResult(snapshot: EvalDetachedCellSnapshot): AgentToolResult<EvalToolDetails> {
447457
const terminationNote =
448-
snapshot.state === "cancelled" && snapshot.language === "js"
449-
? "JavaScript worker was restarted; VM state was lost."
450-
: snapshot.state === "cancelled" && snapshot.language === "py"
451-
? "Python kernel was interrupted; its existing variables are preserved."
452-
: undefined;
458+
snapshot.state === "cancelled" ? interruptionStateNote(snapshot.language, snapshot.stateRetained) : undefined;
453459
const text = [
454460
`Eval cell ${snapshot.cellId} (${snapshot.language}) is ${snapshot.state}.`,
455461
snapshot.outputTail.length === 0 ? "(no buffered output)" : snapshot.outputTail,

0 commit comments

Comments
 (0)