@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
|
@@ -1,55 +1,37 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
3
3
|
import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
|
|
4
4
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
5
|
import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
|
|
6
|
-
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
7
6
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
8
7
|
import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
9
|
-
import {
|
|
8
|
+
import { defaultCodemodeSettings } from "../config/settings.ts";
|
|
10
9
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
11
|
-
import type { InterpreterAvailability } from "../interpreters/detect.ts";
|
|
12
|
-
import { JuliaKernel } from "../kernels/jl/kernel.ts";
|
|
13
10
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
14
|
-
import {
|
|
15
|
-
import { RubyKernel } from "../kernels/rb/kernel.ts";
|
|
16
|
-
import type { SessionEnvironment } from "../kernels/session-env.ts";
|
|
11
|
+
import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
|
|
17
12
|
import { marshalToolResult } from "../tool/image.ts";
|
|
18
|
-
import type { EvalKernel,
|
|
13
|
+
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
14
|
+
import {
|
|
15
|
+
javaScriptKernelMemory,
|
|
16
|
+
registerKernel,
|
|
17
|
+
type StartedKernel,
|
|
18
|
+
startSubprocessKernel,
|
|
19
|
+
} from "./kernel-registration.ts";
|
|
20
|
+
import { kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
|
|
21
|
+
import { ReplaceableKernel } from "./kernel-replacement.ts";
|
|
19
22
|
import { assertSessionCwdAvailable } from "./session-cwd.ts";
|
|
23
|
+
import type {
|
|
24
|
+
BridgeEndpoint,
|
|
25
|
+
CodemodeSessionManager,
|
|
26
|
+
CreateCodemodeSessionManagerOptions,
|
|
27
|
+
} from "./session-manager-contract.ts";
|
|
20
28
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface BridgeEndpoint {
|
|
29
|
-
readonly port: number;
|
|
30
|
-
readonly token: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface EvalExecutionTracker {
|
|
34
|
-
assertEvalExecutionAllowed(): void;
|
|
35
|
-
trackEvalExecution<Result>(execution: Promise<Result>, controller: AbortController): Promise<Result>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface CreateCodemodeSessionManagerOptions {
|
|
39
|
-
readonly sessionId: string;
|
|
40
|
-
readonly cwd: string;
|
|
41
|
-
readonly settings: CodemodeSettings;
|
|
42
|
-
readonly availability: InterpreterAvailability;
|
|
43
|
-
/** Session-scoped roots exposed to kernel helpers such as local://. */
|
|
44
|
-
readonly localRoots?: Readonly<Record<string, string>>;
|
|
45
|
-
/** Session-adjacent directory used for persisted eval artifacts. */
|
|
46
|
-
readonly artifactsDir?: string;
|
|
47
|
-
/** Per-session PI_* values exposed to every kernel and the children it spawns. */
|
|
48
|
-
readonly sessionEnv?: SessionEnvironment;
|
|
49
|
-
readonly executeTool: ExecuteTool;
|
|
50
|
-
readonly listTools?: () => readonly EvalSchemaToolInfo[];
|
|
51
|
-
readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
|
|
52
|
-
}
|
|
29
|
+
export type {
|
|
30
|
+
BridgeEndpoint,
|
|
31
|
+
CodemodeSessionManager,
|
|
32
|
+
CreateCodemodeSessionManagerOptions,
|
|
33
|
+
EvalExecutionTracker,
|
|
34
|
+
} from "./session-manager-contract.ts";
|
|
53
35
|
|
|
54
36
|
export async function createCodemodeSessionManager(
|
|
55
37
|
options: CreateCodemodeSessionManagerOptions,
|
|
@@ -79,6 +61,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
79
61
|
readonly #options: CreateCodemodeSessionManagerOptions;
|
|
80
62
|
#bridge: BridgeServerHandle | undefined;
|
|
81
63
|
#kernels = new Map<EvalLanguage, EvalKernel>();
|
|
64
|
+
readonly #registrations = new Map<EvalLanguage, string>();
|
|
82
65
|
#kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
|
|
83
66
|
#onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
|
|
84
67
|
#context: ExtensionContext | undefined;
|
|
@@ -187,6 +170,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
187
170
|
const kernels = [...this.#kernels.values()];
|
|
188
171
|
const bridge = this.#bridge;
|
|
189
172
|
this.#kernels.clear();
|
|
173
|
+
for (const id of this.#registrations.values()) kernelRegistry.unregister(id);
|
|
174
|
+
this.#registrations.clear();
|
|
190
175
|
this.#onMessageRefs.clear();
|
|
191
176
|
this.#bridge = undefined;
|
|
192
177
|
this.#context = undefined;
|
|
@@ -209,21 +194,49 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
209
194
|
generation: number,
|
|
210
195
|
): Promise<EvalKernel> {
|
|
211
196
|
await assertSessionCwdAvailable(this.#options.cwd);
|
|
212
|
-
|
|
197
|
+
// py/rb/jl instances can die; the session holds one replaceable kernel per language so every
|
|
198
|
+
// cell that kept a reference to it survives the death (JS heals its own worker).
|
|
199
|
+
let memory: RegisteredKernelSource | undefined;
|
|
200
|
+
const kernel =
|
|
201
|
+
language === "js"
|
|
202
|
+
? (() => {
|
|
203
|
+
return this.#createKernel(language, onMessage).then((created) => {
|
|
204
|
+
memory = created.memory;
|
|
205
|
+
return created.kernel;
|
|
206
|
+
});
|
|
207
|
+
})()
|
|
208
|
+
: ReplaceableKernel.create(language, async (lifecycle) => {
|
|
209
|
+
const created = await this.#createKernel(language, onMessage, lifecycle);
|
|
210
|
+
memory ??= created.memory;
|
|
211
|
+
return created.kernel;
|
|
212
|
+
});
|
|
213
|
+
const resolvedKernel = await kernel;
|
|
213
214
|
if (generation !== this.#generation) {
|
|
214
|
-
await
|
|
215
|
+
await resolvedKernel.close();
|
|
215
216
|
throw new CodemodeSessionDisposedError();
|
|
216
217
|
}
|
|
217
|
-
this.#kernels.set(language,
|
|
218
|
+
this.#kernels.set(language, resolvedKernel);
|
|
219
|
+
this.#registrations.set(
|
|
220
|
+
language,
|
|
221
|
+
registerKernel(
|
|
222
|
+
this.#options.ownerSessionId ?? this.#options.sessionId,
|
|
223
|
+
language,
|
|
224
|
+
memory as RegisteredKernelSource,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
218
227
|
// The directory can vanish while the interpreter starts; every caller sharing this creation
|
|
219
228
|
// must see that, not only the next one. The kernel stays stored and dispose still closes it.
|
|
220
229
|
await assertSessionCwdAvailable(this.#options.cwd);
|
|
221
230
|
// A dispose that started during the check above already owns this stored kernel.
|
|
222
231
|
if (generation !== this.#generation) throw new CodemodeSessionDisposedError();
|
|
223
|
-
return
|
|
232
|
+
return resolvedKernel;
|
|
224
233
|
}
|
|
225
234
|
|
|
226
|
-
async #createKernel(
|
|
235
|
+
async #createKernel(
|
|
236
|
+
language: EvalLanguage,
|
|
237
|
+
onMessage: (message: KernelToHostMessage) => void,
|
|
238
|
+
lifecycle: KernelLifecycle = {},
|
|
239
|
+
): Promise<StartedKernel> {
|
|
227
240
|
const bridge = this.#bridge;
|
|
228
241
|
if (!bridge) throw new Error("codemode bridge server is not running");
|
|
229
242
|
const configuredPoolWidth = this.#options.settings.parallelPoolWidth;
|
|
@@ -235,7 +248,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
235
248
|
this.#options.localRoots ??
|
|
236
249
|
(this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
|
|
237
250
|
if (language === "js") {
|
|
238
|
-
|
|
251
|
+
const kernel = new JavaScriptKernel({
|
|
239
252
|
sessionId: this.#options.sessionId,
|
|
240
253
|
cwd: this.#options.cwd,
|
|
241
254
|
parallelPoolWidth,
|
|
@@ -248,6 +261,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
248
261
|
...(localRoots ? { localRoots: { ...localRoots } } : {}),
|
|
249
262
|
...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
|
|
250
263
|
});
|
|
264
|
+
return { kernel, memory: javaScriptKernelMemory(kernel) };
|
|
251
265
|
}
|
|
252
266
|
const detected = this.#options.availability[language].detected;
|
|
253
267
|
if (!detected.ok) throw new Error(`No ${language} interpreter is available`);
|
|
@@ -264,13 +278,10 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
264
278
|
...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
|
|
265
279
|
connection,
|
|
266
280
|
onMessage,
|
|
281
|
+
...lifecycle,
|
|
267
282
|
};
|
|
268
283
|
const memory = resolveKernelMemoryThresholds(this.#options.settings.memory);
|
|
269
|
-
|
|
270
|
-
// rb/jl runners report no memory: the host reads the interpreter footprint for the ceiling only.
|
|
271
|
-
const hostMeasured = { thresholds: memory, readFootprint: readProcessFootprint };
|
|
272
|
-
if (language === "rb") return RubyKernel.start({ ...shared, command: detected.path, memory: hostMeasured });
|
|
273
|
-
return JuliaKernel.start({ ...shared, command: detected.path, memory: hostMeasured });
|
|
284
|
+
return await startSubprocessKernel({ language, interpreterPath: detected.path, memory, shared });
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
#foreignKernelToolNames(): string[] {
|
package/src/kernels/jl/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 JuliaKernelStartOptions {
|
|
8
|
+
export interface JuliaKernelStartOptions extends KernelLifecycle {
|
|
8
9
|
readonly cwd: string;
|
|
9
10
|
readonly sessionId: string;
|
|
10
11
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -52,6 +53,7 @@ export class JuliaKernel extends SubprocessKernel {
|
|
|
52
53
|
spawn: options.spawn,
|
|
53
54
|
onMessage: options.onMessage,
|
|
54
55
|
memory: options.memory && { language: "jl", ...options.memory },
|
|
56
|
+
onDeath: options.onDeath,
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { HostToKernelMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import {
|
|
3
|
+
awaitCooperativeSettlement,
|
|
4
|
+
type JavaScriptInterruptBounds,
|
|
5
|
+
restartedResult,
|
|
6
|
+
restartOutcome,
|
|
7
|
+
type WorkerRetirement,
|
|
8
|
+
} from "./interrupt-bounds.ts";
|
|
9
|
+
import type { JavaScriptRunQueue, PendingJavaScriptRun } from "./run-queue.ts";
|
|
10
|
+
|
|
11
|
+
export interface CellStopOutcome {
|
|
12
|
+
readonly retained: boolean;
|
|
13
|
+
readonly note?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ActiveCellControlHost {
|
|
17
|
+
readonly runs: JavaScriptRunQueue;
|
|
18
|
+
readonly bounds: JavaScriptInterruptBounds;
|
|
19
|
+
post(message: HostToKernelMessage): void;
|
|
20
|
+
terminate(): Promise<WorkerRetirement>;
|
|
21
|
+
/** Brings a fresh worker up once the stopped one is retired. */
|
|
22
|
+
recover(): void;
|
|
23
|
+
clearToolCalls(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The running cell's deadline and the protocol that stops it on a timeout or an interrupt. */
|
|
27
|
+
export class ActiveCellControl {
|
|
28
|
+
readonly #host: ActiveCellControlHost;
|
|
29
|
+
#timeout: NodeJS.Timeout | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(host: ActiveCellControlHost) {
|
|
32
|
+
this.#host = host;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
arm(run: PendingJavaScriptRun): void {
|
|
36
|
+
const timeoutMs = run.input.timeoutMs;
|
|
37
|
+
if (timeoutMs) this.#timeout = setTimeout(() => void this.#timedOut(run, timeoutMs), timeoutMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disarm(): void {
|
|
41
|
+
if (this.#timeout) clearTimeout(this.#timeout);
|
|
42
|
+
this.#timeout = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Asks the worker to settle the active cell cooperatively (rejecting its bridge calls and killing its
|
|
47
|
+
* children); only a cell that stays unsettled past the grace costs the worker VM. Reports whether the
|
|
48
|
+
* worker state survived and, when a blocked worker had to be abandoned, the note that explains it.
|
|
49
|
+
*/
|
|
50
|
+
async stop(run: PendingJavaScriptRun, reason: string, message: string, durationMs = 0): Promise<CellStopOutcome> {
|
|
51
|
+
const host = this.#host;
|
|
52
|
+
try {
|
|
53
|
+
run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
|
|
54
|
+
run.interruptAck ??= Promise.withResolvers<void>();
|
|
55
|
+
host.post({ type: "interrupt", reason });
|
|
56
|
+
if ((await awaitCooperativeSettlement(run, host.bounds)) === "settled") {
|
|
57
|
+
return { retained: run.settledByWorker };
|
|
58
|
+
}
|
|
59
|
+
if (!host.runs.releaseActive(run)) return { retained: run.settledByWorker };
|
|
60
|
+
const retirement = await host.terminate();
|
|
61
|
+
host.runs.settle(run, restartedResult(run, message));
|
|
62
|
+
host.recover();
|
|
63
|
+
return restartOutcome(run, retirement, host.bounds);
|
|
64
|
+
} finally {
|
|
65
|
+
host.clearToolCalls();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #timedOut(run: PendingJavaScriptRun, durationMs: number): Promise<void> {
|
|
70
|
+
if (this.#host.runs.active !== run || run.settled) return;
|
|
71
|
+
await this.stop(run, `timed out after ${durationMs}ms`, `JS cell timed out after ${durationMs}ms`, durationMs);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP
|
|
1
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
awaitCooperativeSettlement,
|
|
7
|
-
DEFAULT_INTERRUPT_BOUNDS,
|
|
8
|
-
type JavaScriptInterruptBounds,
|
|
9
|
-
restartedResult,
|
|
10
|
-
restartOutcome,
|
|
11
|
-
type WorkerRetirement,
|
|
12
|
-
} from "./interrupt-bounds.ts";
|
|
4
|
+
import { ActiveCellControl } from "./active-cell-control.ts";
|
|
5
|
+
import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
13
6
|
import {
|
|
14
7
|
assertJavaScriptKernelOpen,
|
|
15
8
|
type JavaScriptKernelMode,
|
|
@@ -19,6 +12,7 @@ import {
|
|
|
19
12
|
resolveKernelToolNameSource,
|
|
20
13
|
type ToolCallMessage,
|
|
21
14
|
} from "./kernel-contract.ts";
|
|
15
|
+
import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
|
|
22
16
|
import { kernelToolError } from "./kernel-tools-errors.ts";
|
|
23
17
|
import { KernelToolHostPump } from "./kernel-tools-host.ts";
|
|
24
18
|
import type {
|
|
@@ -27,47 +21,53 @@ import type {
|
|
|
27
21
|
KernelToolsInvokeRequest,
|
|
28
22
|
} from "./kernel-tools-types.ts";
|
|
29
23
|
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
24
|
+
import { JavaScriptRunQueue } from "./run-queue.ts";
|
|
25
|
+
import { ToolCallQueue } from "./tool-call-queue.ts";
|
|
26
|
+
import { WorkerChildren } from "./worker-children.ts";
|
|
27
|
+
import { crashedResult } from "./worker-host.ts";
|
|
28
|
+
import { WorkerRecovery } from "./worker-recovery.ts";
|
|
33
29
|
import { WorkerSlot } from "./worker-slot.ts";
|
|
34
30
|
|
|
35
31
|
export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScriptRunInput } from "./kernel-contract.ts";
|
|
32
|
+
export type { JavaScriptMemoryReading } from "./kernel-memory-bridge.ts";
|
|
36
33
|
export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
37
34
|
export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
|
|
38
35
|
|
|
39
|
-
/** How long a lost worker's children get to honour SIGTERM before the host sends SIGKILL. */
|
|
40
|
-
const WORKER_LOSS_CHILD_GRACE_MS = 1_000;
|
|
41
|
-
|
|
42
|
-
// Pull-API fallback queue bound: a nextToolCall consumer this far behind is already stalled, and the
|
|
43
|
-
// normal push path (onMessage) never reads the queue, so unbounded growth only pins tool args (#1695).
|
|
44
|
-
const MAX_PENDING_TOOL_CALLS = 256;
|
|
45
|
-
|
|
46
36
|
export class JavaScriptKernel {
|
|
47
37
|
readonly #options: JavaScriptKernelOptions;
|
|
48
38
|
readonly #moduleLoader: LocalModuleLoader;
|
|
49
39
|
readonly #slot: WorkerSlot;
|
|
50
40
|
#lifecycle: LifecycleState = "open";
|
|
51
41
|
#activation: Promise<void> | null = null;
|
|
52
|
-
#recovery: Promise<void> | null = null;
|
|
53
42
|
#closePromise: Promise<void> | null = null;
|
|
54
43
|
readonly #runs = new JavaScriptRunQueue();
|
|
55
44
|
readonly #kernelTools = new KernelToolHostPump(
|
|
56
45
|
(message) => this.#slot.postMessage(message),
|
|
57
46
|
() => this.#lifecycle === "open" && this.#slot.present,
|
|
58
47
|
);
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
readonly #
|
|
64
|
-
|
|
65
|
-
|
|
48
|
+
readonly #toolCalls = new ToolCallQueue();
|
|
49
|
+
readonly #children: WorkerChildren;
|
|
50
|
+
readonly #memory: KernelMemoryBridge;
|
|
51
|
+
readonly #activeCell: ActiveCellControl;
|
|
52
|
+
readonly #recovery = new WorkerRecovery({
|
|
53
|
+
runs: this.#runs,
|
|
54
|
+
isOpen: () => this.#lifecycle === "open",
|
|
55
|
+
ensureReady: () => this.#ensureReady(),
|
|
56
|
+
startNext: () => this.#startNext(),
|
|
57
|
+
});
|
|
66
58
|
|
|
67
59
|
constructor(options: JavaScriptKernelOptions) {
|
|
68
60
|
this.#options = options;
|
|
69
|
-
this.#
|
|
70
|
-
|
|
61
|
+
this.#activeCell = new ActiveCellControl({
|
|
62
|
+
runs: this.#runs,
|
|
63
|
+
bounds: options.interruptBounds ?? DEFAULT_INTERRUPT_BOUNDS,
|
|
64
|
+
post: (message) => this.#slot.postMessage(message),
|
|
65
|
+
terminate: () => this.#terminate(),
|
|
66
|
+
recover: () => void this.#recovery.recover(() => Promise.resolve()),
|
|
67
|
+
clearToolCalls: () => this.#toolCalls.clear(),
|
|
68
|
+
});
|
|
69
|
+
this.#memory = new KernelMemoryBridge(options.memory, options.onMemoryCollected);
|
|
70
|
+
this.#children = new WorkerChildren(options.collectOrphanedChildren);
|
|
71
71
|
this.#moduleLoader = new LocalModuleLoader(options);
|
|
72
72
|
this.#slot = new WorkerSlot(options, {
|
|
73
73
|
isOpen: () => this.#lifecycle === "open",
|
|
@@ -80,6 +80,17 @@ export class JavaScriptKernel {
|
|
|
80
80
|
return this.#slot.mode;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** The last heap reading the worker sent (a result, an idle collection, a query); none before the first. */
|
|
84
|
+
get lastLiveBytes(): number | undefined {
|
|
85
|
+
return this.#memory.lastLiveBytes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A heap reading taken between cells without running one; nothing when no worker is live to ask. */
|
|
89
|
+
async queryMemory(): Promise<JavaScriptMemoryReading | undefined> {
|
|
90
|
+
if (this.#lifecycle !== "open" || !this.#slot.present || this.#slot.startingUp) return undefined;
|
|
91
|
+
return await this.#memory.query((message) => this.#slot.postMessage(message));
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
get kernelToolEvents(): EventTarget {
|
|
84
95
|
return this.#kernelTools.events;
|
|
85
96
|
}
|
|
@@ -125,15 +136,15 @@ export class JavaScriptKernel {
|
|
|
125
136
|
await this.#restartAfterStop();
|
|
126
137
|
return { stateRetained: Promise.resolve(false) };
|
|
127
138
|
}
|
|
128
|
-
this.#
|
|
129
|
-
const stop = await this.#
|
|
139
|
+
this.#activeCell.disarm();
|
|
140
|
+
const stop = await this.#activeCell.stop(active, reason, `JS cell interrupted: ${reason}`);
|
|
130
141
|
return { stateRetained: Promise.resolve(stop.retained), ...(stop.note === undefined ? {} : { note: stop.note }) };
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
async reset(): Promise<void> {
|
|
134
145
|
assertJavaScriptKernelOpen(this.#lifecycle, "reset");
|
|
135
146
|
await this.#terminate();
|
|
136
|
-
this.#
|
|
147
|
+
this.#toolCalls.clear();
|
|
137
148
|
assertJavaScriptKernelOpen(this.#lifecycle, "reset");
|
|
138
149
|
await this.#ensureReady();
|
|
139
150
|
this.#startNext();
|
|
@@ -144,9 +155,7 @@ export class JavaScriptKernel {
|
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
async nextToolCall(): Promise<ToolCallMessage> {
|
|
147
|
-
|
|
148
|
-
if (pending) return pending;
|
|
149
|
-
return await new Promise((resolve) => this.#toolWaiters.push(resolve));
|
|
158
|
+
return await this.#toolCalls.next();
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
async close(): Promise<void> {
|
|
@@ -155,8 +164,8 @@ export class JavaScriptKernel {
|
|
|
155
164
|
this.#lifecycle = "closing";
|
|
156
165
|
this.#runs.settleAll("JS kernel closed");
|
|
157
166
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JS kernel closed"));
|
|
158
|
-
this.#
|
|
159
|
-
const recovery = this.#recovery;
|
|
167
|
+
this.#toolCalls.clear();
|
|
168
|
+
const recovery = this.#recovery.inFlight;
|
|
160
169
|
const closePromise = (async () => {
|
|
161
170
|
if (recovery) await recovery;
|
|
162
171
|
await this.#terminate();
|
|
@@ -169,7 +178,7 @@ export class JavaScriptKernel {
|
|
|
169
178
|
|
|
170
179
|
#activate(): void {
|
|
171
180
|
if (this.#activation || this.#lifecycle !== "open" || this.#runs.active || !this.#runs.hasWaiting) return;
|
|
172
|
-
const activation = this.#
|
|
181
|
+
const activation = this.#recovery.bringUp();
|
|
173
182
|
this.#activation = activation;
|
|
174
183
|
void activation.then(() => {
|
|
175
184
|
if (this.#activation === activation) this.#activation = null;
|
|
@@ -177,16 +186,6 @@ export class JavaScriptKernel {
|
|
|
177
186
|
});
|
|
178
187
|
}
|
|
179
188
|
|
|
180
|
-
async #activateWhenReady(): Promise<void> {
|
|
181
|
-
try {
|
|
182
|
-
await this.#ensureReady();
|
|
183
|
-
if (this.#lifecycle === "open") this.#startNext();
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (error instanceof WorkerStartupCancelledError) return;
|
|
186
|
-
this.#runs.rejectWaiting(error instanceof Error ? error : new Error(String(error)));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
189
|
async #ensureReady(): Promise<void> {
|
|
191
190
|
assertJavaScriptKernelOpen(this.#lifecycle, "run");
|
|
192
191
|
await this.#slot.ensureReady();
|
|
@@ -196,9 +195,7 @@ export class JavaScriptKernel {
|
|
|
196
195
|
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
197
196
|
const next = this.#runs.startNext(performance.now());
|
|
198
197
|
if (!next) return;
|
|
199
|
-
|
|
200
|
-
this.#timeout = setTimeout(() => void this.#timeoutActive(next), next.input.timeoutMs);
|
|
201
|
-
}
|
|
198
|
+
this.#activeCell.arm(next);
|
|
202
199
|
this.#slot.postMessage({
|
|
203
200
|
type: "kernel-tools-names",
|
|
204
201
|
hostToolNames: resolveKernelToolNameSource(this.#options.hostToolNames),
|
|
@@ -212,178 +209,62 @@ export class JavaScriptKernel {
|
|
|
212
209
|
});
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
async #timeoutActive(run: PendingJavaScriptRun): Promise<void> {
|
|
216
|
-
if (this.#runs.active !== run || run.settled) return;
|
|
217
|
-
const durationMs = run.input.timeoutMs ?? 0;
|
|
218
|
-
await this.#stopActive(
|
|
219
|
-
run,
|
|
220
|
-
`timed out after ${durationMs}ms`,
|
|
221
|
-
`JS cell timed out after ${durationMs}ms`,
|
|
222
|
-
durationMs,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Asks the worker to settle the active cell cooperatively (rejecting its bridge calls and killing its
|
|
228
|
-
* children); only a cell that stays unsettled past the grace costs the worker VM. Reports whether the
|
|
229
|
-
* worker state survived and, when a blocked worker had to be abandoned, the note that explains it.
|
|
230
|
-
*/
|
|
231
|
-
async #stopActive(
|
|
232
|
-
run: PendingJavaScriptRun,
|
|
233
|
-
reason: string,
|
|
234
|
-
message: string,
|
|
235
|
-
durationMs = 0,
|
|
236
|
-
): Promise<{ readonly retained: boolean; readonly note?: string }> {
|
|
237
|
-
try {
|
|
238
|
-
run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
|
|
239
|
-
run.interruptAck ??= Promise.withResolvers<void>();
|
|
240
|
-
this.#slot.postMessage({ type: "interrupt", reason });
|
|
241
|
-
if ((await awaitCooperativeSettlement(run, this.#interruptBounds)) === "settled") {
|
|
242
|
-
return { retained: run.settledByWorker };
|
|
243
|
-
}
|
|
244
|
-
if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
|
|
245
|
-
const retirement = await this.#terminate();
|
|
246
|
-
this.#runs.settle(run, restartedResult(run, message));
|
|
247
|
-
void this.#recover(() => Promise.resolve());
|
|
248
|
-
return restartOutcome(run, retirement, this.#interruptBounds);
|
|
249
|
-
} finally {
|
|
250
|
-
this.#clearToolCalls();
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
212
|
async #restartAfterStop(): Promise<void> {
|
|
255
|
-
await this.#recover(() => this.#terminate());
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** One recovery at a time: retire through `retire` (a no-op when the worker is already gone), then bring a fresh worker up. */
|
|
259
|
-
async #recover(retire: () => Promise<unknown>): Promise<void> {
|
|
260
|
-
if (this.#recovery) return await this.#recovery;
|
|
261
|
-
const recovery = this.#performRecovery(retire);
|
|
262
|
-
this.#recovery = recovery;
|
|
263
|
-
try {
|
|
264
|
-
await recovery;
|
|
265
|
-
} finally {
|
|
266
|
-
if (this.#recovery === recovery) this.#recovery = null;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
async #performRecovery(retire: () => Promise<unknown>): Promise<void> {
|
|
271
|
-
try {
|
|
272
|
-
await retire();
|
|
273
|
-
if (this.#lifecycle !== "open") return;
|
|
274
|
-
await this.#ensureReady();
|
|
275
|
-
if (this.#lifecycle === "open") this.#startNext();
|
|
276
|
-
} catch (error) {
|
|
277
|
-
if (error instanceof WorkerStartupCancelledError) return;
|
|
278
|
-
this.#runs.rejectWaiting(error instanceof Error ? error : new Error(String(error)));
|
|
279
|
-
}
|
|
213
|
+
await this.#recovery.recover(() => this.#terminate());
|
|
280
214
|
}
|
|
281
215
|
|
|
282
216
|
#handleMessage(message: KernelToHostMessage): void {
|
|
283
217
|
if (this.#kernelTools.consume(message) && message.type !== "tool-call") return;
|
|
218
|
+
if (this.#memory.consume(message)) return;
|
|
284
219
|
if (message.type === "status" && message.event.op === INTERRUPT_ACK_OP) {
|
|
285
220
|
this.#runs.acknowledgeInterrupt(message.event);
|
|
286
221
|
return;
|
|
287
222
|
}
|
|
288
223
|
if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
|
|
289
|
-
this.#
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
if (message.type === "status" && message.event.op === MEMORY_COLLECTED_OP) {
|
|
293
|
-
const liveBytes = message.event.liveBytes;
|
|
294
|
-
if (typeof liveBytes !== "number") return;
|
|
295
|
-
this.#memory?.observeLive(liveBytes);
|
|
296
|
-
this.#options.onMemoryCollected?.(liveBytes);
|
|
224
|
+
this.#children.track(message.event);
|
|
297
225
|
return;
|
|
298
226
|
}
|
|
299
227
|
(this.#runs.active?.input.onMessage ?? this.#options.onMessage)?.(message);
|
|
300
228
|
if (message.type === "tool-call") {
|
|
301
|
-
|
|
302
|
-
if (waiter) waiter(message);
|
|
303
|
-
else {
|
|
304
|
-
this.#pendingToolCalls.push(message);
|
|
305
|
-
if (this.#pendingToolCalls.length > MAX_PENDING_TOOL_CALLS) this.#pendingToolCalls.shift();
|
|
306
|
-
}
|
|
229
|
+
this.#toolCalls.push(message);
|
|
307
230
|
return;
|
|
308
231
|
}
|
|
309
232
|
if (message.type !== "result") return;
|
|
310
233
|
const active = this.#runs.active;
|
|
311
234
|
if (!active || active.input.cellId !== message.cellId) return;
|
|
312
|
-
this.#
|
|
235
|
+
this.#activeCell.disarm();
|
|
313
236
|
this.#runs.releaseActive(active);
|
|
314
237
|
active.settledByWorker = true;
|
|
315
|
-
this.#runs.settle(active, active.interruptResult ?? this.#
|
|
238
|
+
this.#runs.settle(active, active.interruptResult ?? this.#memory.settled(message));
|
|
316
239
|
this.#startNext();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
#withMemoryPolicy(message: ResultMessage): ResultMessage {
|
|
321
|
-
if (this.#memory === null || message.memory === undefined) return message;
|
|
322
|
-
return { ...message, memory: this.#memory.annotate(message.memory) };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** A kernel over its memory ceiling restarts only once no cell is running or queued on it. */
|
|
326
|
-
#recycleOverCeilingWhenIdle(): void {
|
|
327
|
-
if (this.#memory?.recyclePending !== true || this.#runs.active || this.#runs.hasWaiting) return;
|
|
328
|
-
this.#memory.recycleStarted();
|
|
329
|
-
void this.#restartAfterStop();
|
|
240
|
+
// A kernel over its memory ceiling restarts only once no cell is running or queued on it.
|
|
241
|
+
if (this.#memory.claimRecycle(!this.#runs.active && !this.#runs.hasWaiting)) void this.#restartAfterStop();
|
|
330
242
|
}
|
|
331
243
|
|
|
332
244
|
#handleCrash(error: Error): void {
|
|
333
245
|
const active = this.#runs.active;
|
|
334
246
|
if (!active && this.#slot.startingUp) return;
|
|
335
|
-
this.#
|
|
247
|
+
this.#activeCell.disarm();
|
|
336
248
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", error.message));
|
|
249
|
+
this.#memory.workerLost(error);
|
|
337
250
|
if (active) {
|
|
338
251
|
this.#runs.releaseActive(active);
|
|
339
|
-
this.#runs.settle(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
error: bridgeError(error),
|
|
344
|
-
durationMs: this.#runs.durationMs(active, performance.now()),
|
|
345
|
-
});
|
|
252
|
+
this.#runs.settle(
|
|
253
|
+
active,
|
|
254
|
+
crashedResult(active.input.cellId, error, this.#runs.durationMs(active, performance.now())),
|
|
255
|
+
);
|
|
346
256
|
}
|
|
347
|
-
this.#
|
|
257
|
+
this.#toolCalls.clear();
|
|
348
258
|
void this.#restartAfterStop();
|
|
349
259
|
}
|
|
350
260
|
|
|
351
|
-
|
|
352
|
-
this.#pendingToolCalls.length = 0;
|
|
353
|
-
this.#toolWaiters.length = 0;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
#clearTimeout(): void {
|
|
357
|
-
if (this.#timeout) clearTimeout(this.#timeout);
|
|
358
|
-
this.#timeout = null;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
#trackChildEvent(event: EvalStatusEvent): void {
|
|
362
|
-
const pid = event.pid;
|
|
363
|
-
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
|
|
364
|
-
if (event.state === "spawned") this.#childPids.add(pid);
|
|
365
|
-
else if (event.state === "exited") this.#childPids.delete(pid);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Retire the worker, then whatever cell children it still owned: a terminated or crashed worker never
|
|
370
|
-
* reaches its own cell-end cleanup, and a pid `ps` no longer lists as our child was reused and is skipped.
|
|
371
|
-
*/
|
|
261
|
+
/** Retire the worker, then whatever cell children it still owned. */
|
|
372
262
|
async #terminate(): Promise<WorkerRetirement> {
|
|
373
|
-
this.#
|
|
263
|
+
this.#activeCell.disarm();
|
|
374
264
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JavaScript worker reset"));
|
|
375
|
-
this.#memory
|
|
265
|
+
this.#memory.workerLost(new Error("JavaScript worker reset"));
|
|
376
266
|
const retirement = await this.#slot.retire();
|
|
377
|
-
await this.#
|
|
267
|
+
await this.#children.retire();
|
|
378
268
|
return retirement;
|
|
379
269
|
}
|
|
380
|
-
|
|
381
|
-
async #retireWorkerChildren(): Promise<void> {
|
|
382
|
-
if (this.#childPids.size === 0) return;
|
|
383
|
-
const pids = [...this.#childPids];
|
|
384
|
-
this.#childPids.clear();
|
|
385
|
-
await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
|
|
386
|
-
// The worker that owned these children and their exit watchers is gone, so nothing else will wait on them.
|
|
387
|
-
await this.#options.collectOrphanedChildren?.(pids);
|
|
388
|
-
}
|
|
389
270
|
}
|