@code-yeongyu/senpi-codemode 2026.10.3 → 2026.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/bridge/memory-protocol.ts +14 -0
- package/src/bridge/protocol.ts +19 -1
- package/src/extension/kernel-registration.ts +96 -0
- package/src/extension/kernel-registry.ts +105 -0
- package/src/extension/kernel-replacement.ts +230 -0
- package/src/extension/runtime-factory.ts +1 -0
- package/src/extension/session-manager-contract.ts +42 -0
- package/src/extension/session-manager.ts +63 -52
- package/src/kernels/jl/kernel.ts +3 -1
- package/src/kernels/js/active-cell-control.ts +73 -0
- package/src/kernels/js/context-manager.ts +69 -188
- package/src/kernels/js/kernel-memory-bridge.ts +110 -0
- package/src/kernels/js/tool-call-queue.ts +32 -0
- package/src/kernels/js/worker-children.ts +35 -0
- package/src/kernels/js/worker-core.js +8 -0
- package/src/kernels/js/worker-host.ts +9 -0
- package/src/kernels/js/worker-recovery.ts +60 -0
- package/src/kernels/py/kernel-contract.ts +2 -1
- package/src/kernels/py/kernel.ts +48 -8
- package/src/kernels/py/transport.ts +25 -4
- package/src/kernels/rb/kernel.ts +3 -1
- package/src/kernels/session-env.ts +3 -0
- package/src/kernels/shared/kernel-death.ts +39 -0
- package/src/kernels/shared/subprocess-contract.ts +2 -1
- package/src/kernels/shared/subprocess-kernel.ts +33 -2
- package/src/kernels/shared/subprocess-queue.ts +6 -0
- package/src/tool/cell-runtime.ts +17 -4
- package/src/tool/detached-cell-notification.ts +13 -2
- package/src/tool/types.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,38 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.5] - 2026-10-03
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Eval kernels expose the session's `OMO_BROWSER_ENGINE` and clear a value inherited from the host process for a session that chose no engine ([#2611](https://github.com/code-yeongyu/senpi/issues/2611)).
|
|
22
|
+
- A Python, Ruby, or Julia eval kernel whose interpreter dies is replaced once instead of failing every later cell: Ruby and Julia no longer stay closed after a crash, and a Python kernel whose stuck interpreter finally exits recovers instead of rejecting every cell. Cells queued behind the death keep their order and run on the replacement, whose first result says `[<language> kernel was restarted after <reason>; every global is lost]`; the cell that was running fails once and is never re-run, and a replacement that dies before finishing a cell fails the queued cells with `eval_kernel_unavailable` ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
|
|
30
|
+
## [2026.10.4] - 2026-10-03
|
|
31
|
+
|
|
32
|
+
### Breaking Changes
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- 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)).
|
|
37
|
+
|
|
38
|
+
- 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))
|
|
39
|
+
|
|
40
|
+
- 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))
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
|
|
15
47
|
## [2026.10.3] - 2026-10-03
|
|
16
48
|
|
|
17
49
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -339,6 +339,17 @@ bash tool for long-running commands you may want to stop. Native `Bun.$`
|
|
|
339
339
|
cancellation is tracked in [Bun #11868](https://github.com/oven-sh/bun/issues/11868);
|
|
340
340
|
the shell's interpretation and object redirects are not replaced.
|
|
341
341
|
|
|
342
|
+
When a Python, Ruby, or Julia interpreter dies on its own (a crash, an OOM kill,
|
|
343
|
+
`os.kill(os.getpid(), 9)`), the cell it was running fails once and is never run
|
|
344
|
+
again: its side effects may already have happened. Cells queued behind it keep
|
|
345
|
+
their order, callbacks, and deadlines and run on one fresh interpreter started
|
|
346
|
+
for that death; the first result there carries
|
|
347
|
+
`[<language> kernel was restarted after <reason>; every global is lost]`. If the
|
|
348
|
+
fresh interpreter dies too before it finished a cell, the queued cells fail with
|
|
349
|
+
`eval_kernel_unavailable` naming the reason, and the next cell you run starts
|
|
350
|
+
another. An interpreter whose exit cannot be confirmed is reported, never
|
|
351
|
+
replaced by a second one. The JavaScript worker keeps its own restart path.
|
|
352
|
+
|
|
342
353
|
Commands a cell runs through `Bun.$` never read the host's terminal: the worker
|
|
343
354
|
thread shares the TUI's stdin, so the shell wrapper hands every template an
|
|
344
355
|
empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
|
|
@@ -425,6 +436,12 @@ bun run --cwd packages/senpi-codemode gate --target <base-checkout> \
|
|
|
425
436
|
--baseline test/gate/baseline.json --write-baseline
|
|
426
437
|
```
|
|
427
438
|
|
|
439
|
+
Record with the canonical runtimes, so the baseline's runtime observations do not
|
|
440
|
+
flip between recordings: Bun 1.4.2, Python 3.14, Julia 1.12 and Ruby 4.x from
|
|
441
|
+
Homebrew (put `/opt/homebrew/opt/ruby/bin` first on `PATH`; macOS's system Ruby
|
|
442
|
+
2.6 records a different runtime). Check `ruby --version` and `python3 --version`
|
|
443
|
+
before `--write-baseline`.
|
|
444
|
+
|
|
428
445
|
The report is gitignored `gate-report.json` by default (`--report <path>` overrides it).
|
|
429
446
|
`test/gate/allowlist.json` contains reviewed additive changes keyed by plan node;
|
|
430
447
|
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.5",
|
|
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.5",
|
|
35
36
|
"typebox": "1.3.34"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
39
|
+
"@code-yeongyu/senpi": "2026.10.5"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
42
|
+
"@code-yeongyu/senpi": "2026.10.5"
|
|
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,8 +87,15 @@ const hostToKernelMessageSchema = Type.Union([
|
|
|
82
87
|
error: bridgeErrorSchema,
|
|
83
88
|
}),
|
|
84
89
|
...kernelToolHostToKernelSchemas,
|
|
90
|
+
...kernelMemoryQueryHostToKernelSchemas,
|
|
85
91
|
]);
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Host-set: what a kernel death did to this cell's state. `lost`: the interpreter died while it ran;
|
|
95
|
+
* `restarted`: it ran on a kernel replaced after a death; `not-run`: it was failed without running.
|
|
96
|
+
*/
|
|
97
|
+
const kernelStateSchema = Type.Union([Type.Literal("lost"), Type.Literal("restarted"), Type.Literal("not-run")]);
|
|
98
|
+
|
|
87
99
|
const kernelToHostMessageSchema = Type.Union([
|
|
88
100
|
Type.Object({ type: Type.Literal("ready") }),
|
|
89
101
|
Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
|
|
@@ -113,6 +125,9 @@ const kernelToHostMessageSchema = Type.Union([
|
|
|
113
125
|
valueRepr: Type.Optional(Type.String()),
|
|
114
126
|
durationMs: Type.Integer({ minimum: 0 }),
|
|
115
127
|
memory: Type.Optional(kernelMemoryReportSchema),
|
|
128
|
+
/** Host-set: the bracketed notice that this cell ran on a kernel restarted after its interpreter died. */
|
|
129
|
+
notice: Type.Optional(Type.String()),
|
|
130
|
+
kernelState: Type.Optional(kernelStateSchema),
|
|
116
131
|
}),
|
|
117
132
|
Type.Object({
|
|
118
133
|
type: Type.Literal("result"),
|
|
@@ -121,10 +136,13 @@ const kernelToHostMessageSchema = Type.Union([
|
|
|
121
136
|
error: bridgeErrorSchema,
|
|
122
137
|
durationMs: Type.Integer({ minimum: 0 }),
|
|
123
138
|
memory: Type.Optional(kernelMemoryReportSchema),
|
|
139
|
+
notice: Type.Optional(Type.String()),
|
|
140
|
+
kernelState: Type.Optional(kernelStateSchema),
|
|
124
141
|
}),
|
|
125
142
|
Type.Object({ type: Type.Literal("closed") }),
|
|
126
143
|
Type.Object({ type: Type.Literal("webview-connect"), requestId: Type.String({ minLength: 1 }) }),
|
|
127
144
|
...kernelToolKernelToHostSchemas,
|
|
145
|
+
...kernelMemoryQueryKernelToHostSchemas,
|
|
128
146
|
]);
|
|
129
147
|
|
|
130
148
|
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();
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { HostToKernelMessage } from "../bridge/protocol.ts";
|
|
2
|
+
import {
|
|
3
|
+
type KernelLifecycle,
|
|
4
|
+
KernelUnavailableError,
|
|
5
|
+
restartNotice,
|
|
6
|
+
unstartedResult,
|
|
7
|
+
} from "../kernels/shared/kernel-death.ts";
|
|
8
|
+
import type {
|
|
9
|
+
EvalKernel,
|
|
10
|
+
EvalKernelResult,
|
|
11
|
+
EvalKernelRunInput,
|
|
12
|
+
EvalLanguage,
|
|
13
|
+
KernelInterruptHandle,
|
|
14
|
+
PendingCell,
|
|
15
|
+
} from "../tool/types.ts";
|
|
16
|
+
|
|
17
|
+
export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
|
|
18
|
+
|
|
19
|
+
class ReplaceableKernelClosedError extends Error {
|
|
20
|
+
readonly name = "ReplaceableKernelClosedError";
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
super("Kernel closed");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one kernel a session holds for a language whose instances can die (py, rb, jl). Every caller keeps
|
|
29
|
+
* this object for the life of a cell - its handler, its execution, its detached record - so a death never
|
|
30
|
+
* strands them: on the instance's own death event this evicts it, takes back the cells that never started,
|
|
31
|
+
* starts ONE replacement and runs them there in their original order, with their own callbacks. The first
|
|
32
|
+
* result of the replacement says the kernel was restarted. A replacement that dies before it finished a
|
|
33
|
+
* cell is not replaced again for the same cells: they fail with `eval_kernel_unavailable`, and the next
|
|
34
|
+
* cell submitted starts a fresh one. The cell that was running when the interpreter died is never re-run.
|
|
35
|
+
*/
|
|
36
|
+
export class ReplaceableKernel implements EvalKernel {
|
|
37
|
+
readonly #language: EvalLanguage;
|
|
38
|
+
readonly #start: StartKernel;
|
|
39
|
+
#kernel: EvalKernel;
|
|
40
|
+
#generation = 0;
|
|
41
|
+
#nextGeneration = 0;
|
|
42
|
+
#held: PendingCell[] = [];
|
|
43
|
+
#replacing: Promise<void> | null = null;
|
|
44
|
+
/** The death the current instance replaced, until that instance settled a cell of its own. */
|
|
45
|
+
#recovering: string | null = null;
|
|
46
|
+
/** A death whose cells were failed instead of replaced: the next cell's fresh instance announces it. */
|
|
47
|
+
#unannounced: string | null = null;
|
|
48
|
+
/** Carried by the first result the current replacement produces. */
|
|
49
|
+
#announce: string | null = null;
|
|
50
|
+
#closed = false;
|
|
51
|
+
#closeFailure: unknown;
|
|
52
|
+
|
|
53
|
+
private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
|
|
54
|
+
this.#language = language;
|
|
55
|
+
this.#start = start;
|
|
56
|
+
this.#kernel = first;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Starts the first instance; one that cannot hand back its queue is returned as it is. */
|
|
60
|
+
static async create(language: EvalLanguage, start: StartKernel): Promise<EvalKernel> {
|
|
61
|
+
let owner: ReplaceableKernel | undefined;
|
|
62
|
+
const first = await start({
|
|
63
|
+
onDeath: (reason) => {
|
|
64
|
+
if (owner !== undefined) owner.#died(0, reason);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
if (first.drainPending === undefined) return first;
|
|
68
|
+
owner = new ReplaceableKernel(language, start, first);
|
|
69
|
+
return owner;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
run(input: EvalKernelRunInput): Promise<EvalKernelResult> {
|
|
73
|
+
if (this.#closed) return Promise.reject(new ReplaceableKernelClosedError());
|
|
74
|
+
if (this.#replacing === null && this.#kernel.isAlive?.() !== false) {
|
|
75
|
+
return this.#submit(this.#generation, this.#kernel, input);
|
|
76
|
+
}
|
|
77
|
+
return new Promise((settle) => {
|
|
78
|
+
this.#held.push({ input, settle });
|
|
79
|
+
this.#ensureReplacement();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
cancelQueued(cellId: string, reason: string): boolean {
|
|
84
|
+
return this.#cancelHeld(cellId, reason) || this.#kernel.cancelQueued(cellId, reason);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async interrupt(reason = "interrupted", cellId?: string): Promise<KernelInterruptHandle> {
|
|
88
|
+
if (cellId !== undefined && this.#cancelHeld(cellId, reason)) return { stateRetained: Promise.resolve(true) };
|
|
89
|
+
if (cellId === undefined) this.#failHeld(reason === "Eval interrupted" ? reason : `Eval interrupted: ${reason}`);
|
|
90
|
+
return await this.#kernel.interrupt(reason, cellId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
queueSnapshot(): { activeCellId: string | null; queuedCellIds: readonly string[] } {
|
|
94
|
+
const current = this.#kernel.queueSnapshot();
|
|
95
|
+
return {
|
|
96
|
+
activeCellId: current.activeCellId,
|
|
97
|
+
queuedCellIds: [...current.queuedCellIds, ...this.#held.map((cell) => cell.input.cellId)],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
|
|
102
|
+
this.#kernel.deliverToolReply(message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async reset(): Promise<void> {
|
|
106
|
+
if (this.#replacing !== null) await this.#replacing;
|
|
107
|
+
if (this.#kernel.isAlive?.() === false) {
|
|
108
|
+
// A reset discards every global anyway, so the fresh instance announces nothing.
|
|
109
|
+
this.#unannounced = null;
|
|
110
|
+
const replacing = this.#replace(null);
|
|
111
|
+
this.#replacing = replacing;
|
|
112
|
+
await replacing;
|
|
113
|
+
if (this.#replacing === replacing) this.#replacing = null;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await this.#kernel.reset();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async close(): Promise<void> {
|
|
120
|
+
this.#closed = true;
|
|
121
|
+
const held = this.#held.splice(0);
|
|
122
|
+
for (const cell of held)
|
|
123
|
+
cell.settle(unstartedResult(cell.input.cellId, new ReplaceableKernelClosedError().message));
|
|
124
|
+
if (this.#replacing !== null) await this.#replacing;
|
|
125
|
+
if (this.#closeFailure !== undefined) throw this.#closeFailure;
|
|
126
|
+
await this.#kernel.close();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
listKernelToolNames(): readonly string[] {
|
|
130
|
+
return this.#kernel.listKernelToolNames?.() ?? [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#submit(generation: number, kernel: EvalKernel, input: EvalKernelRunInput): Promise<EvalKernelResult> {
|
|
134
|
+
return kernel.run(input).then((result) => {
|
|
135
|
+
if (generation !== this.#generation) return result;
|
|
136
|
+
// The result the kernel settled for the cell its death interrupted proves nothing about the
|
|
137
|
+
// replacement; a result from a still-live current instance proves it works.
|
|
138
|
+
// A drained cell settles through this same promise already tagged; only the interrupted one is `lost`.
|
|
139
|
+
if (kernel.isAlive?.() === false)
|
|
140
|
+
return result.kernelState === undefined ? { ...result, kernelState: "lost" } : result;
|
|
141
|
+
this.#recovering = null;
|
|
142
|
+
const notice = this.#announce;
|
|
143
|
+
if (notice === null) return result;
|
|
144
|
+
this.#announce = null;
|
|
145
|
+
return { ...result, notice, kernelState: "restarted" };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#died(generation: number, reason: string): void {
|
|
150
|
+
if (generation !== this.#generation || this.#closed) return;
|
|
151
|
+
this.#held = [...(this.#kernel.drainPending?.() ?? []), ...this.#held];
|
|
152
|
+
if (this.#recovering !== null) {
|
|
153
|
+
this.#recovering = null;
|
|
154
|
+
this.#unannounced = reason;
|
|
155
|
+
const error = new KernelUnavailableError(this.#language, reason);
|
|
156
|
+
for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, error.message));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.#recovering = reason;
|
|
160
|
+
this.#ensureReplacement();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#ensureReplacement(): void {
|
|
164
|
+
if (this.#replacing !== null) return;
|
|
165
|
+
const reason = this.#recovering ?? this.#unannounced;
|
|
166
|
+
this.#recovering = reason;
|
|
167
|
+
this.#unannounced = null;
|
|
168
|
+
const replacing = this.#replace(reason).finally(() => {
|
|
169
|
+
if (this.#replacing === replacing) this.#replacing = null;
|
|
170
|
+
});
|
|
171
|
+
this.#replacing = replacing;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Never rejects: every failure settles the held cells with what went wrong. */
|
|
175
|
+
async #replace(reason: string | null): Promise<void> {
|
|
176
|
+
const dead = this.#kernel;
|
|
177
|
+
try {
|
|
178
|
+
await dead.close();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// The old interpreter may still be running: report that rather than start a second one.
|
|
181
|
+
this.#failUnavailable(reason, `its interpreter could not be retired: ${errorText(error)}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (this.#closed) return;
|
|
185
|
+
const generation = ++this.#nextGeneration;
|
|
186
|
+
let next: EvalKernel;
|
|
187
|
+
try {
|
|
188
|
+
next = await this.#start({ onDeath: (died) => this.#died(generation, died) });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
this.#failUnavailable(reason, `its replacement did not start: ${errorText(error)}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (this.#closed) {
|
|
194
|
+
await next.close().catch((error: unknown) => {
|
|
195
|
+
this.#closeFailure = error;
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.#kernel = next;
|
|
200
|
+
this.#generation = generation;
|
|
201
|
+
this.#announce = reason === null ? null : restartNotice(this.#language, reason);
|
|
202
|
+
for (const cell of this.#held.splice(0)) {
|
|
203
|
+
void this.#submit(generation, next, cell.input).then(cell.settle, (error: unknown) =>
|
|
204
|
+
cell.settle(unstartedResult(cell.input.cellId, errorText(error))),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#failUnavailable(reason: string | null, detail: string): void {
|
|
210
|
+
this.#recovering = null;
|
|
211
|
+
this.#unannounced = reason;
|
|
212
|
+
const error = new KernelUnavailableError(this.#language, reason === null ? detail : `${reason}; ${detail}`);
|
|
213
|
+
for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, error.message));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#cancelHeld(cellId: string, reason: string): boolean {
|
|
217
|
+
const index = this.#held.findIndex((cell) => cell.input.cellId === cellId);
|
|
218
|
+
const [cell] = index < 0 ? [] : this.#held.splice(index, 1);
|
|
219
|
+
cell?.settle(unstartedResult(cellId, reason));
|
|
220
|
+
return cell !== undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#failHeld(message: string): void {
|
|
224
|
+
for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, message));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function errorText(error: unknown): string {
|
|
229
|
+
return error instanceof Error ? error.message : String(error);
|
|
230
|
+
}
|
|
@@ -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
|
+
}
|