@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.
@@ -1,55 +1,37 @@
1
1
  import { join } from "node:path";
2
- import { type ExtensionContext, readProcessFootprint } from "@code-yeongyu/senpi";
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 { type CodemodeSettings, defaultCodemodeSettings } from "../config/settings.ts";
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 { PythonKernel } from "../kernels/py/kernel.ts";
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, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
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 interface CodemodeSessionManager extends EvalKernelManager {
22
- dispose(): Promise<void>;
23
- complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult>;
24
- setContext?(ctx: ExtensionContext): void;
25
- bridgeEndpoint?(): BridgeEndpoint;
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
- const kernel = await this.#createKernel(language, onMessage);
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 kernel.close();
215
+ await resolvedKernel.close();
215
216
  throw new CodemodeSessionDisposedError();
216
217
  }
217
- this.#kernels.set(language, kernel);
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 kernel;
232
+ return resolvedKernel;
224
233
  }
225
234
 
226
- async #createKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
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
- return new JavaScriptKernel({
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
- if (language === "py") return await PythonKernel.start({ ...shared, interpreterPath: detected.path, memory });
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[] {
@@ -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 { EvalStatusEvent, HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
- import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP, MEMORY_COLLECTED_OP } from "../../bridge/reserved.ts";
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 { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
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 { terminateProcessTrees } from "./process-tree-host.ts";
31
- import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
32
- import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
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
- #timeout: NodeJS.Timeout | null = null;
60
- #toolWaiters: Array<(message: ToolCallMessage) => void> = [];
61
- #pendingToolCalls: ToolCallMessage[] = [];
62
- /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
63
- readonly #childPids = new Set<number>();
64
- readonly #memory: KernelMemoryPolicy | null;
65
- readonly #interruptBounds: JavaScriptInterruptBounds;
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.#interruptBounds = options.interruptBounds ?? DEFAULT_INTERRUPT_BOUNDS;
70
- this.#memory = options.memory === undefined ? null : new KernelMemoryPolicy("js", options.memory);
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.#clearTimeout();
129
- const stop = await this.#stopActive(active, reason, `JS cell interrupted: ${reason}`);
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.#clearToolCalls();
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
- const pending = this.#pendingToolCalls.shift();
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.#clearToolCalls();
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.#activateWhenReady();
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
- if (next.input.timeoutMs) {
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.#trackChildEvent(message.event);
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
- const waiter = this.#toolWaiters.shift();
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.#clearTimeout();
235
+ this.#activeCell.disarm();
313
236
  this.#runs.releaseActive(active);
314
237
  active.settledByWorker = true;
315
- this.#runs.settle(active, active.interruptResult ?? this.#withMemoryPolicy(message));
238
+ this.#runs.settle(active, active.interruptResult ?? this.#memory.settled(message));
316
239
  this.#startNext();
317
- this.#recycleOverCeilingWhenIdle();
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.#clearTimeout();
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(active, {
340
- type: "result",
341
- cellId: active.input.cellId,
342
- ok: false,
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.#clearToolCalls();
257
+ this.#toolCalls.clear();
348
258
  void this.#restartAfterStop();
349
259
  }
350
260
 
351
- #clearToolCalls(): void {
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.#clearTimeout();
263
+ this.#activeCell.disarm();
374
264
  this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JavaScript worker reset"));
375
- this.#memory?.kernelRetired();
265
+ this.#memory.workerLost(new Error("JavaScript worker reset"));
376
266
  const retirement = await this.#slot.retire();
377
- await this.#retireWorkerChildren();
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
  }