@code-yeongyu/senpi-codemode 2026.10.3 → 2026.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/bridge/memory-protocol.ts +14 -0
- package/src/bridge/protocol.ts +19 -1
- package/src/extension/kernel-registration.ts +96 -0
- package/src/extension/kernel-registry.ts +105 -0
- package/src/extension/kernel-replacement.ts +230 -0
- package/src/extension/runtime-factory.ts +1 -0
- package/src/extension/session-manager-contract.ts +42 -0
- package/src/extension/session-manager.ts +63 -52
- package/src/kernels/jl/kernel.ts +3 -1
- package/src/kernels/js/active-cell-control.ts +73 -0
- package/src/kernels/js/context-manager.ts +69 -188
- package/src/kernels/js/kernel-memory-bridge.ts +110 -0
- package/src/kernels/js/tool-call-queue.ts +32 -0
- package/src/kernels/js/worker-children.ts +35 -0
- package/src/kernels/js/worker-core.js +8 -0
- package/src/kernels/js/worker-host.ts +9 -0
- package/src/kernels/js/worker-recovery.ts +60 -0
- package/src/kernels/py/kernel-contract.ts +2 -1
- package/src/kernels/py/kernel.ts +48 -8
- package/src/kernels/py/transport.ts +25 -4
- package/src/kernels/rb/kernel.ts +3 -1
- package/src/kernels/session-env.ts +3 -0
- package/src/kernels/shared/kernel-death.ts +39 -0
- package/src/kernels/shared/subprocess-contract.ts +2 -1
- package/src/kernels/shared/subprocess-kernel.ts +33 -2
- package/src/kernels/shared/subprocess-queue.ts +6 -0
- package/src/tool/cell-runtime.ts +17 -4
- package/src/tool/detached-cell-notification.ts +13 -2
- package/src/tool/types.ts +14 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
2
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import { MEMORY_COLLECTED_OP } from "../../bridge/reserved.ts";
|
|
4
|
+
import { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
|
|
5
|
+
import type { ResultMessage } from "./kernel-contract.ts";
|
|
6
|
+
|
|
7
|
+
export interface JavaScriptMemoryReading {
|
|
8
|
+
readonly liveBytes: number;
|
|
9
|
+
readonly measure: "heap";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A query waits at most this long: the worker answers between cells, never inside one. */
|
|
13
|
+
const MEMORY_QUERY_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
type QueryResult = Extract<KernelToHostMessage, { type: "memory-query-result" }>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The host half of one JS kernel's memory: the post-cell policy (notice, ceiling, recycle), the last
|
|
19
|
+
* heap reading the worker sent (result frames, idle collections, queries), and the on-demand queries
|
|
20
|
+
* still waiting for the worker. Losing the worker forgets the reading and rejects every waiting query.
|
|
21
|
+
*/
|
|
22
|
+
export class KernelMemoryBridge {
|
|
23
|
+
readonly #policy: KernelMemoryPolicy | null;
|
|
24
|
+
readonly #onCollected: ((liveBytes: number) => void) | undefined;
|
|
25
|
+
readonly #queries = new Map<
|
|
26
|
+
string,
|
|
27
|
+
{ pending: PromiseWithResolvers<JavaScriptMemoryReading>; timer: ReturnType<typeof setTimeout> }
|
|
28
|
+
>();
|
|
29
|
+
#lastLiveBytes: number | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(thresholds: KernelMemoryThresholds | undefined, onCollected?: (liveBytes: number) => void) {
|
|
32
|
+
this.#policy = thresholds === undefined ? null : new KernelMemoryPolicy("js", thresholds);
|
|
33
|
+
this.#onCollected = onCollected;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get lastLiveBytes(): number | undefined {
|
|
37
|
+
return this.#lastLiveBytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Consumes the worker's memory messages (idle collections, query results); false for anything else. */
|
|
41
|
+
consume(message: KernelToHostMessage): boolean {
|
|
42
|
+
if (message.type === "memory-query-result") {
|
|
43
|
+
this.#answered(message);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (message.type !== "status" || message.event.op !== MEMORY_COLLECTED_OP) return false;
|
|
47
|
+
const liveBytes = message.event.liveBytes;
|
|
48
|
+
if (typeof liveBytes !== "number") return true;
|
|
49
|
+
this.#lastLiveBytes = liveBytes;
|
|
50
|
+
this.#policy?.observeLive(liveBytes);
|
|
51
|
+
this.#onCollected?.(liveBytes);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
settled(message: ResultMessage): ResultMessage {
|
|
56
|
+
if (message.memory === undefined) return message;
|
|
57
|
+
this.#lastLiveBytes = message.memory.liveBytes;
|
|
58
|
+
if (this.#policy === null) return message;
|
|
59
|
+
return { ...message, memory: this.#policy.annotate(message.memory) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when an over-ceiling kernel must restart now: it is `idle` (nothing running or queued). */
|
|
63
|
+
claimRecycle(idle: boolean): boolean {
|
|
64
|
+
if (!idle || this.#policy?.recyclePending !== true) return false;
|
|
65
|
+
this.#policy.recycleStarted();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A fresh reading from the worker. The worker answers between cells, so the wait is bounded:
|
|
71
|
+
* a kernel whose cell never yields (a sync loop) cannot answer until that cell settles, and the
|
|
72
|
+
* query rejects after {@link MEMORY_QUERY_TIMEOUT_MS} instead of hanging its caller forever.
|
|
73
|
+
*/
|
|
74
|
+
query(post: (message: HostToKernelMessage) => void): Promise<JavaScriptMemoryReading> {
|
|
75
|
+
const requestId = crypto.randomUUID();
|
|
76
|
+
const pending = Promise.withResolvers<JavaScriptMemoryReading>();
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
if (!this.#queries.delete(requestId)) return;
|
|
79
|
+
pending.reject(
|
|
80
|
+
new Error(
|
|
81
|
+
`memory-query timed out after ${MEMORY_QUERY_TIMEOUT_MS}ms (a cell is running; queries answer between cells)`,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}, MEMORY_QUERY_TIMEOUT_MS);
|
|
85
|
+
this.#queries.set(requestId, { pending, timer });
|
|
86
|
+
post({ type: "memory-query", requestId });
|
|
87
|
+
return pending.promise;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The worker is gone (reset, crash, stop, recycle): its memory and its unanswered queries went with it. */
|
|
91
|
+
workerLost(error: Error): void {
|
|
92
|
+
this.#policy?.kernelRetired();
|
|
93
|
+
this.#lastLiveBytes = undefined;
|
|
94
|
+
const waiting = [...this.#queries.values()];
|
|
95
|
+
this.#queries.clear();
|
|
96
|
+
for (const { pending, timer } of waiting) {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
pending.reject(error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#answered(result: QueryResult): void {
|
|
103
|
+
const waiting = this.#queries.get(result.requestId);
|
|
104
|
+
if (waiting === undefined) return;
|
|
105
|
+
this.#queries.delete(result.requestId);
|
|
106
|
+
clearTimeout(waiting.timer);
|
|
107
|
+
this.#lastLiveBytes = result.liveBytes;
|
|
108
|
+
waiting.pending.resolve({ liveBytes: result.liveBytes, measure: result.measure });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ToolCallMessage } from "./kernel-contract.ts";
|
|
2
|
+
|
|
3
|
+
// Pull-API fallback queue bound: a nextToolCall consumer this far behind is already stalled, and the
|
|
4
|
+
// normal push path (onMessage) never reads the queue, so unbounded growth only pins tool args (#1695).
|
|
5
|
+
const MAX_PENDING_TOOL_CALLS = 256;
|
|
6
|
+
|
|
7
|
+
/** Tool calls for `nextToolCall()` consumers: handed to a waiting consumer, else queued up to the bound. */
|
|
8
|
+
export class ToolCallQueue {
|
|
9
|
+
readonly #waiters: Array<(message: ToolCallMessage) => void> = [];
|
|
10
|
+
readonly #pending: ToolCallMessage[] = [];
|
|
11
|
+
|
|
12
|
+
push(message: ToolCallMessage): void {
|
|
13
|
+
const waiter = this.#waiters.shift();
|
|
14
|
+
if (waiter) {
|
|
15
|
+
waiter(message);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
this.#pending.push(message);
|
|
19
|
+
if (this.#pending.length > MAX_PENDING_TOOL_CALLS) this.#pending.shift();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async next(): Promise<ToolCallMessage> {
|
|
23
|
+
const pending = this.#pending.shift();
|
|
24
|
+
if (pending) return pending;
|
|
25
|
+
return await new Promise((resolve) => this.#waiters.push(resolve));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
clear(): void {
|
|
29
|
+
this.#pending.length = 0;
|
|
30
|
+
this.#waiters.length = 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EvalStatusEvent } from "../../bridge/protocol.ts";
|
|
2
|
+
import { terminateProcessTrees } from "./process-tree-host.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a lost worker's children get to honour SIGTERM before the host sends SIGKILL. */
|
|
5
|
+
const WORKER_LOSS_CHILD_GRACE_MS = 1_000;
|
|
6
|
+
|
|
7
|
+
/** Live cell children the worker reported; retired by the host when the worker itself is lost. */
|
|
8
|
+
export class WorkerChildren {
|
|
9
|
+
readonly #pids = new Set<number>();
|
|
10
|
+
readonly #collectOrphanedChildren: ((pids: readonly number[]) => Promise<unknown>) | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(collectOrphanedChildren?: (pids: readonly number[]) => Promise<unknown>) {
|
|
13
|
+
this.#collectOrphanedChildren = collectOrphanedChildren;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
track(event: EvalStatusEvent): void {
|
|
17
|
+
const pid = event.pid;
|
|
18
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
|
|
19
|
+
if (event.state === "spawned") this.#pids.add(pid);
|
|
20
|
+
else if (event.state === "exited") this.#pids.delete(pid);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A terminated or crashed worker never reaches its own cell-end cleanup, so the host retires the
|
|
25
|
+
* children it still owned; a pid `ps` no longer lists as our child was reused and is skipped.
|
|
26
|
+
*/
|
|
27
|
+
async retire(): Promise<void> {
|
|
28
|
+
if (this.#pids.size === 0) return;
|
|
29
|
+
const pids = [...this.#pids];
|
|
30
|
+
this.#pids.clear();
|
|
31
|
+
await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
|
|
32
|
+
// The worker that owned these children and their exit watchers is gone, so nothing else will wait on them.
|
|
33
|
+
await this.#collectOrphanedChildren?.(pids);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -3,6 +3,7 @@ import { kernelToolError } from "./kernel-tools-errors.js";
|
|
|
3
3
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
4
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
5
|
import { installSessionCwd } from "./worker-cwd.js";
|
|
6
|
+
import { createHeapProbe } from "./worker-heap.js";
|
|
6
7
|
import { createWorkerMemory } from "./worker-memory.js";
|
|
7
8
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
8
9
|
import { installKernelWebView } from "./worker-webview.js";
|
|
@@ -23,11 +24,13 @@ const SESSION_ENVIRONMENT_KEYS = [
|
|
|
23
24
|
"PI_PROVIDER",
|
|
24
25
|
"PI_MODEL",
|
|
25
26
|
"PI_REASONING_LEVEL",
|
|
27
|
+
"OMO_BROWSER_ENGINE",
|
|
26
28
|
];
|
|
27
29
|
|
|
28
30
|
export function createWorkerCore(transport, options) {
|
|
29
31
|
let runtime = null;
|
|
30
32
|
let memory = null;
|
|
33
|
+
let heapProbe = null;
|
|
31
34
|
let activeCell = null;
|
|
32
35
|
const pendingTools = new Map();
|
|
33
36
|
const pendingWebViewPorts = new Map();
|
|
@@ -146,6 +149,11 @@ export function createWorkerCore(transport, options) {
|
|
|
146
149
|
emit({ type: "ready" });
|
|
147
150
|
return;
|
|
148
151
|
}
|
|
152
|
+
if (message.type === "memory-query") {
|
|
153
|
+
heapProbe ??= createHeapProbe();
|
|
154
|
+
emit({ type: "memory-query-result", requestId: message.requestId, liveBytes: Math.round(heapProbe.estimate()), measure: "heap" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
149
157
|
if (message.type === "run") {
|
|
150
158
|
void runCell(message);
|
|
151
159
|
return;
|
|
@@ -90,6 +90,15 @@ export function bridgeError(error: Error): {
|
|
|
90
90
|
return { message: error.message, name: error.name, stack: error.stack };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** The result a cell settles with when its worker crashed under it. */
|
|
94
|
+
export function crashedResult(
|
|
95
|
+
cellId: string,
|
|
96
|
+
error: Error,
|
|
97
|
+
durationMs: number,
|
|
98
|
+
): Extract<KernelToHostMessage, { type: "result" }> {
|
|
99
|
+
return { type: "result", cellId, ok: false, error: bridgeError(error), durationMs };
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
|
|
94
103
|
return {
|
|
95
104
|
mode,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { JavaScriptRunQueue } from "./run-queue.ts";
|
|
2
|
+
import { WorkerStartupCancelledError } from "./worker-host.ts";
|
|
3
|
+
|
|
4
|
+
export interface WorkerRecoveryHost {
|
|
5
|
+
readonly runs: JavaScriptRunQueue;
|
|
6
|
+
isOpen(): boolean;
|
|
7
|
+
ensureReady(): Promise<void>;
|
|
8
|
+
startNext(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Brings a worker up for waiting cells, and replaces a lost or retired worker one recovery at a time. */
|
|
12
|
+
export class WorkerRecovery {
|
|
13
|
+
readonly #host: WorkerRecoveryHost;
|
|
14
|
+
#recovery: Promise<void> | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(host: WorkerRecoveryHost) {
|
|
17
|
+
this.#host = host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get inFlight(): Promise<void> | null {
|
|
21
|
+
return this.#recovery;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Starts a worker when none is live and hands it the next waiting cell; a failed start fails the waiting cells. */
|
|
25
|
+
async bringUp(): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
await this.#host.ensureReady();
|
|
28
|
+
if (this.#host.isOpen()) this.#host.startNext();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.#failWaiting(error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Retires through `retire` (a no-op when the worker is already gone), then brings a fresh worker up. */
|
|
35
|
+
async recover(retire: () => Promise<unknown>): Promise<void> {
|
|
36
|
+
if (this.#recovery) return await this.#recovery;
|
|
37
|
+
const recovery = this.#perform(retire);
|
|
38
|
+
this.#recovery = recovery;
|
|
39
|
+
try {
|
|
40
|
+
await recovery;
|
|
41
|
+
} finally {
|
|
42
|
+
if (this.#recovery === recovery) this.#recovery = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async #perform(retire: () => Promise<unknown>): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
await retire();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
this.#failWaiting(error);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (this.#host.isOpen()) await this.bringUp();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#failWaiting(error: unknown): void {
|
|
57
|
+
if (error instanceof WorkerStartupCancelledError) return;
|
|
58
|
+
this.#host.runs.rejectWaiting(error instanceof Error ? error : new Error(String(error)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -2,11 +2,12 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalKernelRunInput } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
6
|
import type { KernelSpawnProcess } from "./process.ts";
|
|
6
7
|
import type { PythonStartupStage } from "./startup.ts";
|
|
7
8
|
import type { PythonTransportResult } from "./transport.ts";
|
|
8
9
|
|
|
9
|
-
export interface PythonKernelStartOptions {
|
|
10
|
+
export interface PythonKernelStartOptions extends KernelLifecycle {
|
|
10
11
|
readonly interpreterPath: string;
|
|
11
12
|
readonly sessionId: string;
|
|
12
13
|
readonly cwd: string;
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
|
|
2
|
-
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
2
|
+
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
3
3
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
4
4
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
5
|
+
import { describeExit } from "../shared/kernel-death.ts";
|
|
5
6
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
6
7
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
7
8
|
import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
|
|
@@ -27,6 +28,7 @@ export class PythonKernel {
|
|
|
27
28
|
readonly #memory: KernelMemoryHost | null;
|
|
28
29
|
#generation = 0;
|
|
29
30
|
#closed = false;
|
|
31
|
+
#dead = false;
|
|
30
32
|
|
|
31
33
|
private constructor(options: PythonKernelStartOptions) {
|
|
32
34
|
this.#options = options;
|
|
@@ -43,6 +45,18 @@ export class PythonKernel {
|
|
|
43
45
|
return [];
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
isAlive(): boolean {
|
|
49
|
+
return !this.#dead;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
drainPending(): readonly PendingCell[] {
|
|
53
|
+
return [...this.#queue].map((pending) => {
|
|
54
|
+
this.#pending.delete(pending.input.cellId);
|
|
55
|
+
this.#removePending(pending);
|
|
56
|
+
return { input: pending.input, settle: pending.resolve };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
describeKernelTools(_names: readonly string[]): Promise<never> {
|
|
47
61
|
return rejectKernelToolsUnavailable();
|
|
48
62
|
}
|
|
@@ -54,6 +68,7 @@ export class PythonKernel {
|
|
|
54
68
|
run(input: PythonKernelRunOptions): Promise<ResultMessage> {
|
|
55
69
|
if (this.#failure) return Promise.reject(this.#failure);
|
|
56
70
|
if (this.#closed) return Promise.reject(new Error("Python kernel is closed"));
|
|
71
|
+
if (this.#dead) return Promise.reject(new Error("Python kernel died"));
|
|
57
72
|
return new Promise<ResultMessage>((resolve, reject) => {
|
|
58
73
|
const pending: PendingRun = { input, resolve, reject, startedAt: null, timeoutTimer: null };
|
|
59
74
|
this.#pending.set(input.cellId, pending);
|
|
@@ -156,6 +171,7 @@ export class PythonKernel {
|
|
|
156
171
|
#startNext(): void {
|
|
157
172
|
if (
|
|
158
173
|
this.#closed ||
|
|
174
|
+
this.#dead ||
|
|
159
175
|
this.#failure ||
|
|
160
176
|
this.#active ||
|
|
161
177
|
this.#starting ||
|
|
@@ -234,11 +250,11 @@ export class PythonKernel {
|
|
|
234
250
|
isOwned: () => !this.#closed && generation === this.#generation,
|
|
235
251
|
onRetirementFailure: (transport, error) => {
|
|
236
252
|
if (!this.#transport) this.#transport = transport;
|
|
237
|
-
this.#recordFailure(error);
|
|
253
|
+
this.#recoverWhenGone(transport, this.#recordFailure(error));
|
|
238
254
|
},
|
|
239
255
|
onResult: (transport, result) => this.#onResult(transport, result),
|
|
240
256
|
onError: (transport, error) => this.#onError(transport, error),
|
|
241
|
-
onExit: (transport, error) => this.#onExit(transport, error),
|
|
257
|
+
onExit: (transport, error, exit) => this.#onExit(transport, error, describeExit(exit.code, exit.signal)),
|
|
242
258
|
});
|
|
243
259
|
}
|
|
244
260
|
|
|
@@ -262,15 +278,37 @@ export class PythonKernel {
|
|
|
262
278
|
void this.reset().catch(() => undefined);
|
|
263
279
|
}
|
|
264
280
|
|
|
265
|
-
#onExit(transport: PythonKernelTransport, error: Error): void {
|
|
281
|
+
#onExit(transport: PythonKernelTransport, error: Error, reason: string): void {
|
|
266
282
|
if (this.#transport !== transport) return;
|
|
267
283
|
this.#transport = null;
|
|
284
|
+
// With an owner listening, a death is final for this instance: the cells that never started stay
|
|
285
|
+
// queued for `drainPending` and the owner replaces it. Without one it respawns lazily, as before.
|
|
286
|
+
const onDeath = this.#options.onDeath;
|
|
287
|
+
if (onDeath) this.#dead = true;
|
|
268
288
|
const active = this.#active;
|
|
269
289
|
if (active) {
|
|
270
290
|
if (active.resolveStateRetained) active.resolveStateRetained(false);
|
|
271
|
-
|
|
291
|
+
const message = onDeath ? "Python kernel died; every global is lost" : "Python kernel died";
|
|
292
|
+
this.#settleRun(active, failedPythonResult(active.input.cellId, message, error.message));
|
|
272
293
|
}
|
|
273
|
-
|
|
294
|
+
if (onDeath) onDeath(reason);
|
|
295
|
+
else this.#startNext();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A retirement that timed out is reported, and no second interpreter starts while the first may live.
|
|
300
|
+
* Once that process is seen to exit after all, the failure is over: this instance is dead, not broken.
|
|
301
|
+
*/
|
|
302
|
+
#recoverWhenGone(transport: PythonKernelTransport, failure: Error): void {
|
|
303
|
+
const onDeath = this.#options.onDeath;
|
|
304
|
+
if (!onDeath) return;
|
|
305
|
+
void transport.whenGone().then(() => {
|
|
306
|
+
if (this.#closed || this.#failure !== failure) return;
|
|
307
|
+
this.#failure = null;
|
|
308
|
+
this.#dead = true;
|
|
309
|
+
if (this.#transport === transport) this.#transport = null;
|
|
310
|
+
onDeath("an interpreter that outlived its SIGKILL grace");
|
|
311
|
+
});
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
#onError(transport: PythonKernelTransport, error: Error): void {
|
|
@@ -349,8 +387,10 @@ export class PythonKernel {
|
|
|
349
387
|
try {
|
|
350
388
|
await transport.retire();
|
|
351
389
|
} catch (error) {
|
|
352
|
-
if (error instanceof Error) throw
|
|
353
|
-
|
|
390
|
+
if (!(error instanceof Error)) throw error;
|
|
391
|
+
const failure = this.#recordFailure(error);
|
|
392
|
+
this.#recoverWhenGone(transport, failure);
|
|
393
|
+
throw failure;
|
|
354
394
|
}
|
|
355
395
|
if (this.#transport === transport) this.#transport = null;
|
|
356
396
|
})();
|
|
@@ -52,7 +52,11 @@ export interface PythonTransportOptions {
|
|
|
52
52
|
readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
|
|
53
53
|
readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
|
|
54
54
|
readonly onError: (transport: PythonKernelTransport, error: Error) => void;
|
|
55
|
-
readonly onExit: (
|
|
55
|
+
readonly onExit: (
|
|
56
|
+
transport: PythonKernelTransport,
|
|
57
|
+
error: Error,
|
|
58
|
+
exit: { readonly code: number | null; readonly signal: string | null },
|
|
59
|
+
) => void;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
const hardKillWaitMs = 500;
|
|
@@ -79,12 +83,29 @@ export class PythonKernelTransport {
|
|
|
79
83
|
#active = true;
|
|
80
84
|
#exited = false;
|
|
81
85
|
#retirement: Promise<void> | null = null;
|
|
86
|
+
#gone: Promise<void> | null = null;
|
|
87
|
+
#isGone = false;
|
|
82
88
|
|
|
83
89
|
private constructor(options: PythonTransportOptions, child: KernelChild) {
|
|
84
90
|
this.#options = options;
|
|
85
91
|
this.#child = child;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A retirement that timed out is confirmed only by this: the process really exited after all. The
|
|
96
|
+
* watch is attached on demand, so a transport nobody asks this of leaves no listener on its child.
|
|
97
|
+
*/
|
|
98
|
+
whenGone(): Promise<void> {
|
|
99
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
100
|
+
this.#gone ??= new Promise<void>((resolve) => {
|
|
101
|
+
this.#child.once("exit", () => {
|
|
102
|
+
this.#isGone = true;
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return this.#gone;
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
|
|
89
110
|
const scriptPath = resolvePythonPreludePath();
|
|
90
111
|
const invocation = splitCommand(options.interpreterPath);
|
|
@@ -139,7 +160,7 @@ export class PythonKernelTransport {
|
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
async close(): Promise<void> {
|
|
142
|
-
if (this.#exited) return;
|
|
163
|
+
if (this.#exited || this.#isGone) return;
|
|
143
164
|
if (this.#retirement) {
|
|
144
165
|
await this.#retirement;
|
|
145
166
|
return;
|
|
@@ -162,7 +183,7 @@ export class PythonKernelTransport {
|
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
retire(): Promise<void> {
|
|
165
|
-
if (this.#exited) return Promise.resolve();
|
|
186
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
166
187
|
if (this.#retirement) return this.#retirement;
|
|
167
188
|
this.#active = false;
|
|
168
189
|
const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
|
|
@@ -244,7 +265,7 @@ export class PythonKernelTransport {
|
|
|
244
265
|
const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
|
|
245
266
|
this.#detachListeners();
|
|
246
267
|
if (!active) return;
|
|
247
|
-
if (!this.#settleStartup(error)) this.#options.onExit(this, error);
|
|
268
|
+
if (!this.#settleStartup(error)) this.#options.onExit(this, error, { code, signal });
|
|
248
269
|
}
|
|
249
270
|
|
|
250
271
|
#onError(error: Error): void {
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
4
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
6
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
6
7
|
|
|
7
|
-
export interface RubyKernelStartOptions {
|
|
8
|
+
export interface RubyKernelStartOptions extends KernelLifecycle {
|
|
8
9
|
readonly cwd: string;
|
|
9
10
|
readonly sessionId: string;
|
|
10
11
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -41,6 +42,7 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
41
42
|
spawn: options.spawn,
|
|
42
43
|
onMessage: options.onMessage,
|
|
43
44
|
memory: options.memory && { language: "rb", ...options.memory },
|
|
45
|
+
onDeath: options.onDeath,
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -17,6 +17,7 @@ export const SESSION_ENVIRONMENT_KEYS = [
|
|
|
17
17
|
"PI_PROVIDER",
|
|
18
18
|
"PI_MODEL",
|
|
19
19
|
"PI_REASONING_LEVEL",
|
|
20
|
+
"OMO_BROWSER_ENGINE",
|
|
20
21
|
] as const;
|
|
21
22
|
|
|
22
23
|
/** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
|
|
@@ -32,6 +33,7 @@ export interface SessionEnvironmentSource {
|
|
|
32
33
|
};
|
|
33
34
|
readonly model?: { readonly provider: string; readonly id: string } | undefined;
|
|
34
35
|
readonly thinkingLevel?: string | undefined;
|
|
36
|
+
readonly browserEngine?: string | undefined;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
|
|
@@ -47,6 +49,7 @@ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): Sessio
|
|
|
47
49
|
env.PI_MODEL = model.id;
|
|
48
50
|
}
|
|
49
51
|
if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
|
|
52
|
+
if (source.browserEngine) env.OMO_BROWSER_ENGINE = source.browserEngine;
|
|
50
53
|
return env;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from "node:os";
|
|
2
|
+
import type { EvalKernelResult, EvalLanguage } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
/** How the session manager hears that a kernel's interpreter died. */
|
|
5
|
+
export interface KernelLifecycle {
|
|
6
|
+
/** Called once when the interpreter died on its own; the kernel then keeps its unstarted cells for `drainPending`. */
|
|
7
|
+
readonly onDeath?: (reason: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The wording a restart notice uses for an exit nobody asked for: "signal 9", "exit code 1". */
|
|
11
|
+
export function describeExit(code: number | null, signal: string | null): string {
|
|
12
|
+
if (signal !== null) {
|
|
13
|
+
const number = Object.entries(constants.signals).find(([name]) => name === signal)?.[1];
|
|
14
|
+
return `signal ${number ?? signal}`;
|
|
15
|
+
}
|
|
16
|
+
return code === null ? "an exit with no status" : `exit code ${code}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function restartNotice(language: EvalLanguage, reason: string): string {
|
|
20
|
+
return `[${language} kernel was restarted after ${reason}; every global is lost]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const EVAL_KERNEL_UNAVAILABLE = "eval_kernel_unavailable";
|
|
24
|
+
|
|
25
|
+
/** A queued cell that never ran: its kernel died again before the replacement finished one cell. */
|
|
26
|
+
export class KernelUnavailableError extends Error {
|
|
27
|
+
readonly name = "KernelUnavailableError";
|
|
28
|
+
readonly code = EVAL_KERNEL_UNAVAILABLE;
|
|
29
|
+
|
|
30
|
+
constructor(language: EvalLanguage, reason: string) {
|
|
31
|
+
super(
|
|
32
|
+
`${EVAL_KERNEL_UNAVAILABLE}: the ${language} kernel died again (${reason}) before its replacement finished a cell; this cell never ran. Run it again to start a fresh kernel.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function unstartedResult(cellId: string, message: string): EvalKernelResult {
|
|
38
|
+
return { type: "result", cellId, ok: false, error: { message }, durationMs: 0, kernelState: "not-run" };
|
|
39
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalKernelRunInput, EvalLanguage } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { KernelLifecycle } from "./kernel-death.ts";
|
|
5
6
|
import type { FootprintReader } from "./kernel-memory-host.ts";
|
|
6
7
|
import type { SubprocessSpawn } from "./subprocess-process.ts";
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ export interface SubprocessKernelMemory {
|
|
|
16
17
|
readonly readFootprint: FootprintReader;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export interface SubprocessKernelOptions {
|
|
20
|
+
export interface SubprocessKernelOptions extends KernelLifecycle {
|
|
20
21
|
readonly command: string;
|
|
21
22
|
readonly args: readonly string[];
|
|
22
23
|
readonly cwd?: string;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
-
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
3
|
+
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
4
4
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
5
5
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
6
6
|
import { applySessionEnvironment } from "../session-env.ts";
|
|
7
|
+
import { describeExit } from "./kernel-death.ts";
|
|
7
8
|
import { KernelMemoryHost } from "./kernel-memory-host.ts";
|
|
8
9
|
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
9
10
|
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
KernelRetirementError,
|
|
20
21
|
KernelStartupError,
|
|
21
22
|
type PendingRun,
|
|
23
|
+
settlePendingRun,
|
|
22
24
|
timeoutResult,
|
|
23
25
|
} from "./subprocess-run.ts";
|
|
24
26
|
|
|
@@ -43,6 +45,7 @@ export class SubprocessKernel {
|
|
|
43
45
|
private retirementFailure: Error | null = null;
|
|
44
46
|
private failure: Error | null = null;
|
|
45
47
|
private closed = false;
|
|
48
|
+
private dead = false;
|
|
46
49
|
|
|
47
50
|
constructor(options: SubprocessKernelOptions) {
|
|
48
51
|
this.options = options;
|
|
@@ -52,6 +55,14 @@ export class SubprocessKernel {
|
|
|
52
55
|
this.spawnProcess();
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
isAlive(): boolean {
|
|
59
|
+
return !this.dead;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
drainPending(): readonly PendingCell[] {
|
|
63
|
+
return this.runs.drain().map((run) => ({ input: run.input, settle: (result) => settlePendingRun(run, result) }));
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
run(input: KernelRunInput): Promise<KernelResult> {
|
|
56
67
|
if (this.closed) throw new KernelClosedError();
|
|
57
68
|
const run = this.runs.enqueue(input);
|
|
@@ -243,7 +254,27 @@ export class SubprocessKernel {
|
|
|
243
254
|
}
|
|
244
255
|
this.process = null;
|
|
245
256
|
this.processReady = false;
|
|
246
|
-
|
|
257
|
+
const error = new KernelExitedError(signal ?? code ?? "unknown");
|
|
258
|
+
if (this.options.onDeath) this.die(error, describeExit(code, signal), this.options.onDeath);
|
|
259
|
+
else this.failClosed(error);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The interpreter exited on its own and is gone: the running cell fails once (its side effects may
|
|
264
|
+
* already have happened), the cells that never started stay queued for `drainPending`, and the owner
|
|
265
|
+
* replaces this instance. `run()` is never reached on it again.
|
|
266
|
+
*/
|
|
267
|
+
private die(error: Error, reason: string, onDeath: (reason: string) => void): void {
|
|
268
|
+
this.failure = error;
|
|
269
|
+
this.closed = true;
|
|
270
|
+
this.dead = true;
|
|
271
|
+
this.runs.clearToolCalls();
|
|
272
|
+
const active = this.runs.active;
|
|
273
|
+
if (active) {
|
|
274
|
+
this.runs.releaseActive(active);
|
|
275
|
+
this.runs.settle(active, failureResult(active, new Error(`${error.message}; every global is lost`)));
|
|
276
|
+
}
|
|
277
|
+
onDeath(reason);
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
private accepts(process: SubprocessProcess): boolean {
|