@code-yeongyu/senpi-codemode 2026.10.3 → 2026.10.4

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 CHANGED
@@ -12,6 +12,23 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.4] - 2026-10-03
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Every live eval kernel in a process is listed in a process-wide registry with its session, language, measure, and last-known memory reading; a JavaScript kernel keeps the heap reading from each result and idle collection and answers an on-demand heap query between cells without running one, while Python, Ruby, and Julia kernels report their interpreter's footprint on demand. Thresholds, notices, and the result frame are unchanged ([#2561](https://github.com/code-yeongyu/senpi/issues/2561)).
22
+
23
+ - An eval regression gate records the full prompt and schema surfaces, helper witnesses across five required runtime legs, codemode-scoped eager imports, measured teardown resources (including global, named-import, promise and AbortSignal timers, named by creation site) and legacy contract results against a frozen baseline. CI provisions every interpreter and publishes the report; unrelated host imports and slow child startup cannot cause a regression failure. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
24
+
25
+ - Added an interleaved eval timing benchmark that judges each runtime, workload and metric on adjacent base/head pairs against its own A/A-calibrated threshold (capped at 5%, or one shared band with `--band-scope global`), with process CPU accounting across interpreter crashes, a per-row minimum detectable effect, and explicit inconclusive results for incomplete, host-contaminated or noise-limited comparisons. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
26
+ ### Changed
27
+
28
+ ### Fixed
29
+
30
+ ### Removed
31
+
15
32
  ## [2026.10.3] - 2026-10-03
16
33
 
17
34
  ### Breaking Changes
package/README.md CHANGED
@@ -425,6 +425,12 @@ bun run --cwd packages/senpi-codemode gate --target <base-checkout> \
425
425
  --baseline test/gate/baseline.json --write-baseline
426
426
  ```
427
427
 
428
+ Record with the canonical runtimes, so the baseline's runtime observations do not
429
+ flip between recordings: Bun 1.4.2, Python 3.14, Julia 1.12 and Ruby 4.x from
430
+ Homebrew (put `/opt/homebrew/opt/ruby/bin` first on `PATH`; macOS's system Ruby
431
+ 2.6 records a different runtime). Check `ruby --version` and `python3 --version`
432
+ before `--write-baseline`.
433
+
428
434
  The report is gitignored `gate-report.json` by default (`--report <path>` overrides it).
429
435
  `test/gate/allowlist.json` contains reviewed additive changes keyed by plan node;
430
436
  it cannot authorize removal or modification of a legacy entry. The test-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.3",
3
+ "version": "2026.10.4",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,6 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "gate": "bun scripts/gate-eval.ts",
20
+ "bench": "bun scripts/bench-eval.ts",
20
21
  "test": "vitest run test/",
21
22
  "clean": "node -e \"\"",
22
23
  "build": "node -e \"\""
@@ -31,14 +32,14 @@
31
32
  },
32
33
  "dependencies": {
33
34
  "@babel/parser": "8.0.4",
34
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.3",
35
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.4",
35
36
  "typebox": "1.3.34"
36
37
  },
37
38
  "peerDependencies": {
38
- "@code-yeongyu/senpi": "2026.10.3"
39
+ "@code-yeongyu/senpi": "2026.10.4"
39
40
  },
40
41
  "devDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.3"
42
+ "@code-yeongyu/senpi": "2026.10.4"
42
43
  },
43
44
  "keywords": [
44
45
  "senpi",
@@ -34,6 +34,20 @@ export const kernelMemoryReportSchema = Type.Object({
34
34
  notice: Type.Optional(Type.String()),
35
35
  });
36
36
 
37
+ /** On-demand heap reading between cells (JS kernels): no cell runs and no collection is forced. */
38
+ export const kernelMemoryQueryHostToKernelSchemas = [
39
+ Type.Object({ type: Type.Literal("memory-query"), requestId: Type.String({ minLength: 1 }) }),
40
+ ] as const;
41
+
42
+ export const kernelMemoryQueryKernelToHostSchemas = [
43
+ Type.Object({
44
+ type: Type.Literal("memory-query-result"),
45
+ requestId: Type.String({ minLength: 1 }),
46
+ liveBytes: Type.Integer({ minimum: 0 }),
47
+ measure: Type.Literal("heap"),
48
+ }),
49
+ ] as const;
50
+
37
51
  export type KernelMemoryThresholds = Static<typeof kernelMemoryThresholdsSchema>;
38
52
  export type KernelMemoryReport = Static<typeof kernelMemoryReportSchema>;
39
53
  export type KernelMemoryGlobal = Static<typeof kernelMemoryGlobalSchema>;
@@ -2,7 +2,12 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypt
2
2
  import { type Static, Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
  import { kernelToolHostToKernelSchemas, kernelToolKernelToHostSchemas } from "./kernel-tools-protocol.ts";
5
- import { kernelMemoryReportSchema, kernelMemoryThresholdsSchema } from "./memory-protocol.ts";
5
+ import {
6
+ kernelMemoryQueryHostToKernelSchemas,
7
+ kernelMemoryQueryKernelToHostSchemas,
8
+ kernelMemoryReportSchema,
9
+ kernelMemoryThresholdsSchema,
10
+ } from "./memory-protocol.ts";
6
11
 
7
12
  export const BRIDGE_FRAME_MAX_BYTES = 10 * 1024 * 1024;
8
13
 
@@ -82,6 +87,7 @@ const hostToKernelMessageSchema = Type.Union([
82
87
  error: bridgeErrorSchema,
83
88
  }),
84
89
  ...kernelToolHostToKernelSchemas,
90
+ ...kernelMemoryQueryHostToKernelSchemas,
85
91
  ]);
86
92
 
87
93
  const kernelToHostMessageSchema = Type.Union([
@@ -125,6 +131,7 @@ const kernelToHostMessageSchema = Type.Union([
125
131
  Type.Object({ type: Type.Literal("closed") }),
126
132
  Type.Object({ type: Type.Literal("webview-connect"), requestId: Type.String({ minLength: 1 }) }),
127
133
  ...kernelToolKernelToHostSchemas,
134
+ ...kernelMemoryQueryKernelToHostSchemas,
128
135
  ]);
129
136
 
130
137
  const bridgeMessageSchema = Type.Union([hostToKernelMessageSchema, kernelToHostMessageSchema]);
@@ -0,0 +1,96 @@
1
+ import { readProcessFootprint } from "@code-yeongyu/senpi";
2
+ import type { KernelMemoryThresholds } from "../bridge/memory-protocol.ts";
3
+ import type { BridgeConnectionConfig, KernelToHostMessage } from "../bridge/protocol.ts";
4
+ import { JuliaKernel } from "../kernels/jl/kernel.ts";
5
+ import type { JavaScriptKernel } from "../kernels/js/context-manager.ts";
6
+ import { PythonKernel } from "../kernels/py/kernel.ts";
7
+ import { defaultSpawn } from "../kernels/py/process.ts";
8
+ import { RubyKernel } from "../kernels/rb/kernel.ts";
9
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
10
+ import { spawnSubprocess } from "../kernels/shared/subprocess-process.ts";
11
+ import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
+ import { type KernelMemoryReading, kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
13
+
14
+ export interface StartedKernel {
15
+ readonly kernel: EvalKernel;
16
+ readonly memory: RegisteredKernelSource;
17
+ }
18
+
19
+ export interface SubprocessKernelStart {
20
+ readonly language: Exclude<EvalLanguage, "js">;
21
+ readonly interpreterPath: string;
22
+ readonly memory: KernelMemoryThresholds;
23
+ readonly shared: {
24
+ readonly sessionId: string;
25
+ readonly cwd: string;
26
+ readonly sessionEnv?: SessionEnvironment;
27
+ readonly connection: BridgeConnectionConfig;
28
+ readonly onMessage: (message: KernelToHostMessage) => void;
29
+ };
30
+ }
31
+
32
+ export function registerKernel(sessionId: string, language: EvalLanguage, memory: RegisteredKernelSource): string {
33
+ const id = crypto.randomUUID();
34
+ kernelRegistry.register({ id, sessionId, language, source: memory });
35
+ return id;
36
+ }
37
+
38
+ export function javaScriptKernelMemory(kernel: JavaScriptKernel): RegisteredKernelSource {
39
+ return {
40
+ measure: "heap",
41
+ lastLiveBytes: () => kernel.lastLiveBytes,
42
+ busy: () => kernel.queueSnapshot().activeCellId !== null,
43
+ queryMemory: () => kernel.queryMemory(),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Starts a py/rb/jl kernel through a spawn that remembers its interpreter's pid, so the registry can read
49
+ * that process's footprint on demand; a restarted interpreter replaces the pid.
50
+ */
51
+ export async function startSubprocessKernel(start: SubprocessKernelStart): Promise<StartedKernel> {
52
+ let pid: number | undefined;
53
+ const { shared, memory } = start;
54
+ let kernel: EvalKernel;
55
+ if (start.language === "py") {
56
+ kernel = await PythonKernel.start({
57
+ ...shared,
58
+ interpreterPath: start.interpreterPath,
59
+ memory,
60
+ spawnProcess: (options) => {
61
+ const child = defaultSpawn(options);
62
+ pid = child.pid;
63
+ return child;
64
+ },
65
+ });
66
+ } else {
67
+ // rb/jl runners report no memory: the host reads the interpreter footprint for the ceiling only.
68
+ const options = {
69
+ ...shared,
70
+ command: start.interpreterPath,
71
+ memory: { thresholds: memory, readFootprint: readProcessFootprint },
72
+ spawn: (command: string, args: readonly string[], spawnOptions: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
73
+ const child = spawnSubprocess(undefined, { command, args, ...spawnOptions });
74
+ pid = child.pid;
75
+ return child;
76
+ },
77
+ };
78
+ kernel = start.language === "rb" ? RubyKernel.start(options) : JuliaKernel.start(options);
79
+ }
80
+ return { kernel, memory: footprintMemory(kernel, () => pid) };
81
+ }
82
+
83
+ function footprintMemory(kernel: EvalKernel, pid: () => number | undefined): RegisteredKernelSource {
84
+ const read = (): KernelMemoryReading | undefined => {
85
+ const current = pid();
86
+ const footprint = current === undefined ? undefined : readProcessFootprint(current);
87
+ return footprint === undefined ? undefined : { liveBytes: Math.round(footprint.bytes), measure: "footprint" };
88
+ };
89
+ return {
90
+ measure: "footprint",
91
+ lastLiveBytes: () => read()?.liveBytes,
92
+ busy: () => kernel.queueSnapshot().activeCellId !== null,
93
+ queryMemory: async () => read(),
94
+ pid,
95
+ };
96
+ }
@@ -0,0 +1,105 @@
1
+ import type { EvalLanguage } from "../tool/types.ts";
2
+
3
+ /** `heap`: a JS worker's own heap estimate; `footprint`: the interpreter process's memory footprint. */
4
+ export type KernelMeasure = "heap" | "footprint";
5
+
6
+ export interface KernelMemoryReading {
7
+ readonly liveBytes: number;
8
+ readonly measure: KernelMeasure;
9
+ }
10
+
11
+ /**
12
+ * What the registry reads from a live kernel. It is backed by the kernel object, not its worker or
13
+ * process, so a kernel that restarts its worker (ceiling, crash, reset) keeps its registry entry.
14
+ */
15
+ export interface RegisteredKernelSource {
16
+ readonly measure: KernelMeasure;
17
+ /** The last reading the host holds, without asking the kernel; `undefined` before the first one. */
18
+ lastLiveBytes(): number | undefined;
19
+ /** A cell is running, so the last reading may predate what the kernel holds now. */
20
+ busy(): boolean;
21
+ /** A fresh reading taken without running a cell; `undefined` when no worker or process is live. */
22
+ queryMemory(): Promise<KernelMemoryReading | undefined>;
23
+ pid?(): number | undefined;
24
+ }
25
+
26
+ export interface KernelRegistration {
27
+ readonly id: string;
28
+ readonly sessionId: string;
29
+ readonly language: EvalLanguage;
30
+ readonly source: RegisteredKernelSource;
31
+ }
32
+
33
+ export interface KernelListing {
34
+ readonly id: string;
35
+ readonly sessionId: string;
36
+ readonly language: EvalLanguage;
37
+ readonly measure: KernelMeasure;
38
+ readonly lastLiveBytes?: number;
39
+ readonly busy: boolean;
40
+ readonly pid?: number;
41
+ }
42
+
43
+ export interface KernelRegistry {
44
+ register(kernel: KernelRegistration): void;
45
+ unregister(id: string): boolean;
46
+ list(): KernelListing[];
47
+ lastLiveBytes(id: string): number | undefined;
48
+ query(id: string): Promise<KernelMemoryReading | undefined>;
49
+ }
50
+
51
+ // One registry per process, whichever copy of this module asks: the extension loader can evaluate the
52
+ // codemode module graph more than once in a process, and the host's memory report reads the same key.
53
+ const REGISTRY_KEY = Symbol.for("senpi.codemode.kernel-registry");
54
+
55
+ function createKernelRegistry(): KernelRegistry {
56
+ const kernels = new Map<string, KernelRegistration>();
57
+ return {
58
+ register(kernel) {
59
+ kernels.set(kernel.id, kernel);
60
+ },
61
+ unregister(id) {
62
+ return kernels.delete(id);
63
+ },
64
+ list() {
65
+ return [...kernels.values()].map(listing);
66
+ },
67
+ lastLiveBytes(id) {
68
+ return kernels.get(id)?.source.lastLiveBytes();
69
+ },
70
+ async query(id) {
71
+ return await kernels.get(id)?.source.queryMemory();
72
+ },
73
+ };
74
+ }
75
+
76
+ function listing({ id, sessionId, language, source }: KernelRegistration): KernelListing {
77
+ const lastLiveBytes = source.lastLiveBytes();
78
+ const pid = source.pid?.();
79
+ return {
80
+ id,
81
+ sessionId,
82
+ language,
83
+ measure: source.measure,
84
+ busy: source.busy(),
85
+ ...(lastLiveBytes === undefined ? {} : { lastLiveBytes }),
86
+ ...(pid === undefined ? {} : { pid }),
87
+ };
88
+ }
89
+
90
+ function isKernelRegistry(value: unknown): value is KernelRegistry {
91
+ if (typeof value !== "object" || value === null) return false;
92
+ return ["register", "unregister", "list", "lastLiveBytes", "query"].every(
93
+ (name) => typeof Reflect.get(value, name) === "function",
94
+ );
95
+ }
96
+
97
+ function processKernelRegistry(): KernelRegistry {
98
+ const existing: unknown = Reflect.get(globalThis, REGISTRY_KEY);
99
+ if (isKernelRegistry(existing)) return existing;
100
+ const created = createKernelRegistry();
101
+ Reflect.set(globalThis, REGISTRY_KEY, created);
102
+ return created;
103
+ }
104
+
105
+ export const kernelRegistry: KernelRegistry = processKernelRegistry();
@@ -72,6 +72,7 @@ export async function createRuntime(
72
72
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
73
73
  const manager = await create({
74
74
  sessionId,
75
+ ownerSessionId: ctx.sessionManager.getSessionId(),
75
76
  cwd: ctx.cwd,
76
77
  sessionEnv,
77
78
  settings,
@@ -0,0 +1,42 @@
1
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
3
+ import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
+ import type { CodemodeSettings } from "../config/settings.ts";
5
+ import type { InterpreterAvailability } from "../interpreters/detect.ts";
6
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
7
+ import type { EvalKernelManager, ExecuteTool } from "../tool/types.ts";
8
+
9
+ export interface CodemodeSessionManager extends EvalKernelManager {
10
+ dispose(): Promise<void>;
11
+ complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult>;
12
+ setContext?(ctx: ExtensionContext): void;
13
+ bridgeEndpoint?(): BridgeEndpoint;
14
+ }
15
+
16
+ export interface BridgeEndpoint {
17
+ readonly port: number;
18
+ readonly token: string;
19
+ }
20
+
21
+ export interface EvalExecutionTracker {
22
+ assertEvalExecutionAllowed(): void;
23
+ trackEvalExecution<Result>(execution: Promise<Result>, controller: AbortController): Promise<Result>;
24
+ }
25
+
26
+ export interface CreateCodemodeSessionManagerOptions {
27
+ readonly sessionId: string;
28
+ /** The agent session that owns these kernels, as the process kernel registry lists them; defaults to `sessionId`. */
29
+ readonly ownerSessionId?: string;
30
+ readonly cwd: string;
31
+ readonly settings: CodemodeSettings;
32
+ readonly availability: InterpreterAvailability;
33
+ /** Session-scoped roots exposed to kernel helpers such as local://. */
34
+ readonly localRoots?: Readonly<Record<string, string>>;
35
+ /** Session-adjacent directory used for persisted eval artifacts. */
36
+ readonly artifactsDir?: string;
37
+ /** Per-session PI_* values exposed to every kernel and the children it spawns. */
38
+ readonly sessionEnv?: SessionEnvironment;
39
+ readonly executeTool: ExecuteTool;
40
+ readonly listTools?: () => readonly EvalSchemaToolInfo[];
41
+ readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
42
+ }
@@ -1,55 +1,35 @@
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";
17
11
  import { marshalToolResult } from "../tool/image.ts";
18
- import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
12
+ import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
13
+ import {
14
+ javaScriptKernelMemory,
15
+ registerKernel,
16
+ type StartedKernel,
17
+ startSubprocessKernel,
18
+ } from "./kernel-registration.ts";
19
+ import { kernelRegistry } from "./kernel-registry.ts";
19
20
  import { assertSessionCwdAvailable } from "./session-cwd.ts";
21
+ import type {
22
+ BridgeEndpoint,
23
+ CodemodeSessionManager,
24
+ CreateCodemodeSessionManagerOptions,
25
+ } from "./session-manager-contract.ts";
20
26
 
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
- }
27
+ export type {
28
+ BridgeEndpoint,
29
+ CodemodeSessionManager,
30
+ CreateCodemodeSessionManagerOptions,
31
+ EvalExecutionTracker,
32
+ } from "./session-manager-contract.ts";
53
33
 
54
34
  export async function createCodemodeSessionManager(
55
35
  options: CreateCodemodeSessionManagerOptions,
@@ -79,6 +59,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
79
59
  readonly #options: CreateCodemodeSessionManagerOptions;
80
60
  #bridge: BridgeServerHandle | undefined;
81
61
  #kernels = new Map<EvalLanguage, EvalKernel>();
62
+ readonly #registrations = new Map<EvalLanguage, string>();
82
63
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
83
64
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
84
65
  #context: ExtensionContext | undefined;
@@ -187,6 +168,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
187
168
  const kernels = [...this.#kernels.values()];
188
169
  const bridge = this.#bridge;
189
170
  this.#kernels.clear();
171
+ for (const id of this.#registrations.values()) kernelRegistry.unregister(id);
172
+ this.#registrations.clear();
190
173
  this.#onMessageRefs.clear();
191
174
  this.#bridge = undefined;
192
175
  this.#context = undefined;
@@ -209,12 +192,16 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
209
192
  generation: number,
210
193
  ): Promise<EvalKernel> {
211
194
  await assertSessionCwdAvailable(this.#options.cwd);
212
- const kernel = await this.#createKernel(language, onMessage);
195
+ const { kernel, memory } = await this.#createKernel(language, onMessage);
213
196
  if (generation !== this.#generation) {
214
197
  await kernel.close();
215
198
  throw new CodemodeSessionDisposedError();
216
199
  }
217
200
  this.#kernels.set(language, kernel);
201
+ this.#registrations.set(
202
+ language,
203
+ registerKernel(this.#options.ownerSessionId ?? this.#options.sessionId, language, memory),
204
+ );
218
205
  // The directory can vanish while the interpreter starts; every caller sharing this creation
219
206
  // must see that, not only the next one. The kernel stays stored and dispose still closes it.
220
207
  await assertSessionCwdAvailable(this.#options.cwd);
@@ -223,7 +210,10 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
223
210
  return kernel;
224
211
  }
225
212
 
226
- async #createKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
213
+ async #createKernel(
214
+ language: EvalLanguage,
215
+ onMessage: (message: KernelToHostMessage) => void,
216
+ ): Promise<StartedKernel> {
227
217
  const bridge = this.#bridge;
228
218
  if (!bridge) throw new Error("codemode bridge server is not running");
229
219
  const configuredPoolWidth = this.#options.settings.parallelPoolWidth;
@@ -235,7 +225,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
235
225
  this.#options.localRoots ??
236
226
  (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
237
227
  if (language === "js") {
238
- return new JavaScriptKernel({
228
+ const kernel = new JavaScriptKernel({
239
229
  sessionId: this.#options.sessionId,
240
230
  cwd: this.#options.cwd,
241
231
  parallelPoolWidth,
@@ -248,6 +238,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
248
238
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
249
239
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
250
240
  });
241
+ return { kernel, memory: javaScriptKernelMemory(kernel) };
251
242
  }
252
243
  const detected = this.#options.availability[language].detected;
253
244
  if (!detected.ok) throw new Error(`No ${language} interpreter is available`);
@@ -266,11 +257,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
266
257
  onMessage,
267
258
  };
268
259
  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 });
260
+ return await startSubprocessKernel({ language, interpreterPath: detected.path, memory, shared });
274
261
  }
275
262
 
276
263
  #foreignKernelToolNames(): string[] {
@@ -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
  }
@@ -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";
@@ -28,6 +29,7 @@ const SESSION_ENVIRONMENT_KEYS = [
28
29
  export function createWorkerCore(transport, options) {
29
30
  let runtime = null;
30
31
  let memory = null;
32
+ let heapProbe = null;
31
33
  let activeCell = null;
32
34
  const pendingTools = new Map();
33
35
  const pendingWebViewPorts = new Map();
@@ -146,6 +148,11 @@ export function createWorkerCore(transport, options) {
146
148
  emit({ type: "ready" });
147
149
  return;
148
150
  }
151
+ if (message.type === "memory-query") {
152
+ heapProbe ??= createHeapProbe();
153
+ emit({ type: "memory-query-result", requestId: message.requestId, liveBytes: Math.round(heapProbe.estimate()), measure: "heap" });
154
+ return;
155
+ }
149
156
  if (message.type === "run") {
150
157
  void runCell(message);
151
158
  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
+ }
@@ -114,6 +114,7 @@ export class SubprocessRunQueue {
114
114
  onMessage?.(message);
115
115
  return false;
116
116
  case "webview-connect":
117
+ case "memory-query-result":
117
118
  return false;
118
119
  default: {
119
120
  const exhaustive: never = message;