@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 +17 -0
- package/README.md +6 -0
- package/package.json +5 -4
- package/src/bridge/memory-protocol.ts +14 -0
- package/src/bridge/protocol.ts +8 -1
- package/src/extension/kernel-registration.ts +96 -0
- package/src/extension/kernel-registry.ts +105 -0
- package/src/extension/runtime-factory.ts +1 -0
- package/src/extension/session-manager-contract.ts +42 -0
- package/src/extension/session-manager.ts +36 -49
- 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 +7 -0
- package/src/kernels/js/worker-host.ts +9 -0
- package/src/kernels/js/worker-recovery.ts +60 -0
- package/src/kernels/shared/subprocess-queue.ts +1 -0
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
|
+
"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.
|
|
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.
|
|
39
|
+
"@code-yeongyu/senpi": "2026.10.4"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
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>;
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 { 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,
|
|
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
|
|
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
|
-
}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
+
}
|