@code-yeongyu/senpi-codemode 2026.10.4 → 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 +15 -0
- package/README.md +11 -0
- package/package.json +4 -4
- package/src/bridge/protocol.ts +11 -0
- package/src/extension/kernel-replacement.ts +230 -0
- package/src/extension/session-manager.ts +30 -6
- package/src/kernels/jl/kernel.ts +3 -1
- package/src/kernels/js/worker-core.js +1 -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 +5 -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,21 @@
|
|
|
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
|
+
|
|
15
30
|
## [2026.10.4] - 2026-10-03
|
|
16
31
|
|
|
17
32
|
### 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`,
|
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",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@babel/parser": "8.0.4",
|
|
35
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
35
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.5",
|
|
36
36
|
"typebox": "1.3.34"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
39
|
+
"@code-yeongyu/senpi": "2026.10.5"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
42
|
+
"@code-yeongyu/senpi": "2026.10.5"
|
|
43
43
|
},
|
|
44
44
|
"keywords": [
|
|
45
45
|
"senpi",
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -90,6 +90,12 @@ const hostToKernelMessageSchema = Type.Union([
|
|
|
90
90
|
...kernelMemoryQueryHostToKernelSchemas,
|
|
91
91
|
]);
|
|
92
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
|
+
|
|
93
99
|
const kernelToHostMessageSchema = Type.Union([
|
|
94
100
|
Type.Object({ type: Type.Literal("ready") }),
|
|
95
101
|
Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
|
|
@@ -119,6 +125,9 @@ const kernelToHostMessageSchema = Type.Union([
|
|
|
119
125
|
valueRepr: Type.Optional(Type.String()),
|
|
120
126
|
durationMs: Type.Integer({ minimum: 0 }),
|
|
121
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),
|
|
122
131
|
}),
|
|
123
132
|
Type.Object({
|
|
124
133
|
type: Type.Literal("result"),
|
|
@@ -127,6 +136,8 @@ const kernelToHostMessageSchema = Type.Union([
|
|
|
127
136
|
error: bridgeErrorSchema,
|
|
128
137
|
durationMs: Type.Integer({ minimum: 0 }),
|
|
129
138
|
memory: Type.Optional(kernelMemoryReportSchema),
|
|
139
|
+
notice: Type.Optional(Type.String()),
|
|
140
|
+
kernelState: Type.Optional(kernelStateSchema),
|
|
130
141
|
}),
|
|
131
142
|
Type.Object({ type: Type.Literal("closed") }),
|
|
132
143
|
Type.Object({ type: Type.Literal("webview-connect"), requestId: Type.String({ minLength: 1 }) }),
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
|
8
8
|
import { defaultCodemodeSettings } from "../config/settings.ts";
|
|
9
9
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
10
10
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
11
|
+
import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
|
|
11
12
|
import { marshalToolResult } from "../tool/image.ts";
|
|
12
13
|
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
13
14
|
import {
|
|
@@ -16,7 +17,8 @@ import {
|
|
|
16
17
|
type StartedKernel,
|
|
17
18
|
startSubprocessKernel,
|
|
18
19
|
} from "./kernel-registration.ts";
|
|
19
|
-
import { kernelRegistry } from "./kernel-registry.ts";
|
|
20
|
+
import { kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
|
|
21
|
+
import { ReplaceableKernel } from "./kernel-replacement.ts";
|
|
20
22
|
import { assertSessionCwdAvailable } from "./session-cwd.ts";
|
|
21
23
|
import type {
|
|
22
24
|
BridgeEndpoint,
|
|
@@ -192,27 +194,48 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
192
194
|
generation: number,
|
|
193
195
|
): Promise<EvalKernel> {
|
|
194
196
|
await assertSessionCwdAvailable(this.#options.cwd);
|
|
195
|
-
|
|
197
|
+
// py/rb/jl instances can die; the session holds one replaceable kernel per language so every
|
|
198
|
+
// cell that kept a reference to it survives the death (JS heals its own worker).
|
|
199
|
+
let memory: RegisteredKernelSource | undefined;
|
|
200
|
+
const kernel =
|
|
201
|
+
language === "js"
|
|
202
|
+
? (() => {
|
|
203
|
+
return this.#createKernel(language, onMessage).then((created) => {
|
|
204
|
+
memory = created.memory;
|
|
205
|
+
return created.kernel;
|
|
206
|
+
});
|
|
207
|
+
})()
|
|
208
|
+
: ReplaceableKernel.create(language, async (lifecycle) => {
|
|
209
|
+
const created = await this.#createKernel(language, onMessage, lifecycle);
|
|
210
|
+
memory ??= created.memory;
|
|
211
|
+
return created.kernel;
|
|
212
|
+
});
|
|
213
|
+
const resolvedKernel = await kernel;
|
|
196
214
|
if (generation !== this.#generation) {
|
|
197
|
-
await
|
|
215
|
+
await resolvedKernel.close();
|
|
198
216
|
throw new CodemodeSessionDisposedError();
|
|
199
217
|
}
|
|
200
|
-
this.#kernels.set(language,
|
|
218
|
+
this.#kernels.set(language, resolvedKernel);
|
|
201
219
|
this.#registrations.set(
|
|
202
220
|
language,
|
|
203
|
-
registerKernel(
|
|
221
|
+
registerKernel(
|
|
222
|
+
this.#options.ownerSessionId ?? this.#options.sessionId,
|
|
223
|
+
language,
|
|
224
|
+
memory as RegisteredKernelSource,
|
|
225
|
+
),
|
|
204
226
|
);
|
|
205
227
|
// The directory can vanish while the interpreter starts; every caller sharing this creation
|
|
206
228
|
// must see that, not only the next one. The kernel stays stored and dispose still closes it.
|
|
207
229
|
await assertSessionCwdAvailable(this.#options.cwd);
|
|
208
230
|
// A dispose that started during the check above already owns this stored kernel.
|
|
209
231
|
if (generation !== this.#generation) throw new CodemodeSessionDisposedError();
|
|
210
|
-
return
|
|
232
|
+
return resolvedKernel;
|
|
211
233
|
}
|
|
212
234
|
|
|
213
235
|
async #createKernel(
|
|
214
236
|
language: EvalLanguage,
|
|
215
237
|
onMessage: (message: KernelToHostMessage) => void,
|
|
238
|
+
lifecycle: KernelLifecycle = {},
|
|
216
239
|
): Promise<StartedKernel> {
|
|
217
240
|
const bridge = this.#bridge;
|
|
218
241
|
if (!bridge) throw new Error("codemode bridge server is not running");
|
|
@@ -255,6 +278,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
255
278
|
...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
|
|
256
279
|
connection,
|
|
257
280
|
onMessage,
|
|
281
|
+
...lifecycle,
|
|
258
282
|
};
|
|
259
283
|
const memory = resolveKernelMemoryThresholds(this.#options.settings.memory);
|
|
260
284
|
return await startSubprocessKernel({ language, interpreterPath: detected.path, memory, shared });
|
package/src/kernels/jl/kernel.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
4
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
6
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
6
7
|
|
|
7
|
-
export interface JuliaKernelStartOptions {
|
|
8
|
+
export interface JuliaKernelStartOptions extends KernelLifecycle {
|
|
8
9
|
readonly cwd: string;
|
|
9
10
|
readonly sessionId: string;
|
|
10
11
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -52,6 +53,7 @@ export class JuliaKernel extends SubprocessKernel {
|
|
|
52
53
|
spawn: options.spawn,
|
|
53
54
|
onMessage: options.onMessage,
|
|
54
55
|
memory: options.memory && { language: "jl", ...options.memory },
|
|
56
|
+
onDeath: options.onDeath,
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -2,11 +2,12 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalKernelRunInput } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
6
|
import type { KernelSpawnProcess } from "./process.ts";
|
|
6
7
|
import type { PythonStartupStage } from "./startup.ts";
|
|
7
8
|
import type { PythonTransportResult } from "./transport.ts";
|
|
8
9
|
|
|
9
|
-
export interface PythonKernelStartOptions {
|
|
10
|
+
export interface PythonKernelStartOptions extends KernelLifecycle {
|
|
10
11
|
readonly interpreterPath: string;
|
|
11
12
|
readonly sessionId: string;
|
|
12
13
|
readonly cwd: string;
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
|
|
2
|
-
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
2
|
+
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
3
3
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
4
4
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
5
|
+
import { describeExit } from "../shared/kernel-death.ts";
|
|
5
6
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
6
7
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
7
8
|
import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
|
|
@@ -27,6 +28,7 @@ export class PythonKernel {
|
|
|
27
28
|
readonly #memory: KernelMemoryHost | null;
|
|
28
29
|
#generation = 0;
|
|
29
30
|
#closed = false;
|
|
31
|
+
#dead = false;
|
|
30
32
|
|
|
31
33
|
private constructor(options: PythonKernelStartOptions) {
|
|
32
34
|
this.#options = options;
|
|
@@ -43,6 +45,18 @@ export class PythonKernel {
|
|
|
43
45
|
return [];
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
isAlive(): boolean {
|
|
49
|
+
return !this.#dead;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
drainPending(): readonly PendingCell[] {
|
|
53
|
+
return [...this.#queue].map((pending) => {
|
|
54
|
+
this.#pending.delete(pending.input.cellId);
|
|
55
|
+
this.#removePending(pending);
|
|
56
|
+
return { input: pending.input, settle: pending.resolve };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
describeKernelTools(_names: readonly string[]): Promise<never> {
|
|
47
61
|
return rejectKernelToolsUnavailable();
|
|
48
62
|
}
|
|
@@ -54,6 +68,7 @@ export class PythonKernel {
|
|
|
54
68
|
run(input: PythonKernelRunOptions): Promise<ResultMessage> {
|
|
55
69
|
if (this.#failure) return Promise.reject(this.#failure);
|
|
56
70
|
if (this.#closed) return Promise.reject(new Error("Python kernel is closed"));
|
|
71
|
+
if (this.#dead) return Promise.reject(new Error("Python kernel died"));
|
|
57
72
|
return new Promise<ResultMessage>((resolve, reject) => {
|
|
58
73
|
const pending: PendingRun = { input, resolve, reject, startedAt: null, timeoutTimer: null };
|
|
59
74
|
this.#pending.set(input.cellId, pending);
|
|
@@ -156,6 +171,7 @@ export class PythonKernel {
|
|
|
156
171
|
#startNext(): void {
|
|
157
172
|
if (
|
|
158
173
|
this.#closed ||
|
|
174
|
+
this.#dead ||
|
|
159
175
|
this.#failure ||
|
|
160
176
|
this.#active ||
|
|
161
177
|
this.#starting ||
|
|
@@ -234,11 +250,11 @@ export class PythonKernel {
|
|
|
234
250
|
isOwned: () => !this.#closed && generation === this.#generation,
|
|
235
251
|
onRetirementFailure: (transport, error) => {
|
|
236
252
|
if (!this.#transport) this.#transport = transport;
|
|
237
|
-
this.#recordFailure(error);
|
|
253
|
+
this.#recoverWhenGone(transport, this.#recordFailure(error));
|
|
238
254
|
},
|
|
239
255
|
onResult: (transport, result) => this.#onResult(transport, result),
|
|
240
256
|
onError: (transport, error) => this.#onError(transport, error),
|
|
241
|
-
onExit: (transport, error) => this.#onExit(transport, error),
|
|
257
|
+
onExit: (transport, error, exit) => this.#onExit(transport, error, describeExit(exit.code, exit.signal)),
|
|
242
258
|
});
|
|
243
259
|
}
|
|
244
260
|
|
|
@@ -262,15 +278,37 @@ export class PythonKernel {
|
|
|
262
278
|
void this.reset().catch(() => undefined);
|
|
263
279
|
}
|
|
264
280
|
|
|
265
|
-
#onExit(transport: PythonKernelTransport, error: Error): void {
|
|
281
|
+
#onExit(transport: PythonKernelTransport, error: Error, reason: string): void {
|
|
266
282
|
if (this.#transport !== transport) return;
|
|
267
283
|
this.#transport = null;
|
|
284
|
+
// With an owner listening, a death is final for this instance: the cells that never started stay
|
|
285
|
+
// queued for `drainPending` and the owner replaces it. Without one it respawns lazily, as before.
|
|
286
|
+
const onDeath = this.#options.onDeath;
|
|
287
|
+
if (onDeath) this.#dead = true;
|
|
268
288
|
const active = this.#active;
|
|
269
289
|
if (active) {
|
|
270
290
|
if (active.resolveStateRetained) active.resolveStateRetained(false);
|
|
271
|
-
|
|
291
|
+
const message = onDeath ? "Python kernel died; every global is lost" : "Python kernel died";
|
|
292
|
+
this.#settleRun(active, failedPythonResult(active.input.cellId, message, error.message));
|
|
272
293
|
}
|
|
273
|
-
|
|
294
|
+
if (onDeath) onDeath(reason);
|
|
295
|
+
else this.#startNext();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A retirement that timed out is reported, and no second interpreter starts while the first may live.
|
|
300
|
+
* Once that process is seen to exit after all, the failure is over: this instance is dead, not broken.
|
|
301
|
+
*/
|
|
302
|
+
#recoverWhenGone(transport: PythonKernelTransport, failure: Error): void {
|
|
303
|
+
const onDeath = this.#options.onDeath;
|
|
304
|
+
if (!onDeath) return;
|
|
305
|
+
void transport.whenGone().then(() => {
|
|
306
|
+
if (this.#closed || this.#failure !== failure) return;
|
|
307
|
+
this.#failure = null;
|
|
308
|
+
this.#dead = true;
|
|
309
|
+
if (this.#transport === transport) this.#transport = null;
|
|
310
|
+
onDeath("an interpreter that outlived its SIGKILL grace");
|
|
311
|
+
});
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
#onError(transport: PythonKernelTransport, error: Error): void {
|
|
@@ -349,8 +387,10 @@ export class PythonKernel {
|
|
|
349
387
|
try {
|
|
350
388
|
await transport.retire();
|
|
351
389
|
} catch (error) {
|
|
352
|
-
if (error instanceof Error) throw
|
|
353
|
-
|
|
390
|
+
if (!(error instanceof Error)) throw error;
|
|
391
|
+
const failure = this.#recordFailure(error);
|
|
392
|
+
this.#recoverWhenGone(transport, failure);
|
|
393
|
+
throw failure;
|
|
354
394
|
}
|
|
355
395
|
if (this.#transport === transport) this.#transport = null;
|
|
356
396
|
})();
|
|
@@ -52,7 +52,11 @@ export interface PythonTransportOptions {
|
|
|
52
52
|
readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
|
|
53
53
|
readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
|
|
54
54
|
readonly onError: (transport: PythonKernelTransport, error: Error) => void;
|
|
55
|
-
readonly onExit: (
|
|
55
|
+
readonly onExit: (
|
|
56
|
+
transport: PythonKernelTransport,
|
|
57
|
+
error: Error,
|
|
58
|
+
exit: { readonly code: number | null; readonly signal: string | null },
|
|
59
|
+
) => void;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
const hardKillWaitMs = 500;
|
|
@@ -79,12 +83,29 @@ export class PythonKernelTransport {
|
|
|
79
83
|
#active = true;
|
|
80
84
|
#exited = false;
|
|
81
85
|
#retirement: Promise<void> | null = null;
|
|
86
|
+
#gone: Promise<void> | null = null;
|
|
87
|
+
#isGone = false;
|
|
82
88
|
|
|
83
89
|
private constructor(options: PythonTransportOptions, child: KernelChild) {
|
|
84
90
|
this.#options = options;
|
|
85
91
|
this.#child = child;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A retirement that timed out is confirmed only by this: the process really exited after all. The
|
|
96
|
+
* watch is attached on demand, so a transport nobody asks this of leaves no listener on its child.
|
|
97
|
+
*/
|
|
98
|
+
whenGone(): Promise<void> {
|
|
99
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
100
|
+
this.#gone ??= new Promise<void>((resolve) => {
|
|
101
|
+
this.#child.once("exit", () => {
|
|
102
|
+
this.#isGone = true;
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return this.#gone;
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
|
|
89
110
|
const scriptPath = resolvePythonPreludePath();
|
|
90
111
|
const invocation = splitCommand(options.interpreterPath);
|
|
@@ -139,7 +160,7 @@ export class PythonKernelTransport {
|
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
async close(): Promise<void> {
|
|
142
|
-
if (this.#exited) return;
|
|
163
|
+
if (this.#exited || this.#isGone) return;
|
|
143
164
|
if (this.#retirement) {
|
|
144
165
|
await this.#retirement;
|
|
145
166
|
return;
|
|
@@ -162,7 +183,7 @@ export class PythonKernelTransport {
|
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
retire(): Promise<void> {
|
|
165
|
-
if (this.#exited) return Promise.resolve();
|
|
186
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
166
187
|
if (this.#retirement) return this.#retirement;
|
|
167
188
|
this.#active = false;
|
|
168
189
|
const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
|
|
@@ -244,7 +265,7 @@ export class PythonKernelTransport {
|
|
|
244
265
|
const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
|
|
245
266
|
this.#detachListeners();
|
|
246
267
|
if (!active) return;
|
|
247
|
-
if (!this.#settleStartup(error)) this.#options.onExit(this, error);
|
|
268
|
+
if (!this.#settleStartup(error)) this.#options.onExit(this, error, { code, signal });
|
|
248
269
|
}
|
|
249
270
|
|
|
250
271
|
#onError(error: Error): void {
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
4
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
6
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
6
7
|
|
|
7
|
-
export interface RubyKernelStartOptions {
|
|
8
|
+
export interface RubyKernelStartOptions extends KernelLifecycle {
|
|
8
9
|
readonly cwd: string;
|
|
9
10
|
readonly sessionId: string;
|
|
10
11
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -41,6 +42,7 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
41
42
|
spawn: options.spawn,
|
|
42
43
|
onMessage: options.onMessage,
|
|
43
44
|
memory: options.memory && { language: "rb", ...options.memory },
|
|
45
|
+
onDeath: options.onDeath,
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -17,6 +17,7 @@ export const SESSION_ENVIRONMENT_KEYS = [
|
|
|
17
17
|
"PI_PROVIDER",
|
|
18
18
|
"PI_MODEL",
|
|
19
19
|
"PI_REASONING_LEVEL",
|
|
20
|
+
"OMO_BROWSER_ENGINE",
|
|
20
21
|
] as const;
|
|
21
22
|
|
|
22
23
|
/** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
|
|
@@ -32,6 +33,7 @@ export interface SessionEnvironmentSource {
|
|
|
32
33
|
};
|
|
33
34
|
readonly model?: { readonly provider: string; readonly id: string } | undefined;
|
|
34
35
|
readonly thinkingLevel?: string | undefined;
|
|
36
|
+
readonly browserEngine?: string | undefined;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
|
|
@@ -47,6 +49,7 @@ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): Sessio
|
|
|
47
49
|
env.PI_MODEL = model.id;
|
|
48
50
|
}
|
|
49
51
|
if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
|
|
52
|
+
if (source.browserEngine) env.OMO_BROWSER_ENGINE = source.browserEngine;
|
|
50
53
|
return env;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from "node:os";
|
|
2
|
+
import type { EvalKernelResult, EvalLanguage } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
/** How the session manager hears that a kernel's interpreter died. */
|
|
5
|
+
export interface KernelLifecycle {
|
|
6
|
+
/** Called once when the interpreter died on its own; the kernel then keeps its unstarted cells for `drainPending`. */
|
|
7
|
+
readonly onDeath?: (reason: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The wording a restart notice uses for an exit nobody asked for: "signal 9", "exit code 1". */
|
|
11
|
+
export function describeExit(code: number | null, signal: string | null): string {
|
|
12
|
+
if (signal !== null) {
|
|
13
|
+
const number = Object.entries(constants.signals).find(([name]) => name === signal)?.[1];
|
|
14
|
+
return `signal ${number ?? signal}`;
|
|
15
|
+
}
|
|
16
|
+
return code === null ? "an exit with no status" : `exit code ${code}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function restartNotice(language: EvalLanguage, reason: string): string {
|
|
20
|
+
return `[${language} kernel was restarted after ${reason}; every global is lost]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const EVAL_KERNEL_UNAVAILABLE = "eval_kernel_unavailable";
|
|
24
|
+
|
|
25
|
+
/** A queued cell that never ran: its kernel died again before the replacement finished one cell. */
|
|
26
|
+
export class KernelUnavailableError extends Error {
|
|
27
|
+
readonly name = "KernelUnavailableError";
|
|
28
|
+
readonly code = EVAL_KERNEL_UNAVAILABLE;
|
|
29
|
+
|
|
30
|
+
constructor(language: EvalLanguage, reason: string) {
|
|
31
|
+
super(
|
|
32
|
+
`${EVAL_KERNEL_UNAVAILABLE}: the ${language} kernel died again (${reason}) before its replacement finished a cell; this cell never ran. Run it again to start a fresh kernel.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function unstartedResult(cellId: string, message: string): EvalKernelResult {
|
|
38
|
+
return { type: "result", cellId, ok: false, error: { message }, durationMs: 0, kernelState: "not-run" };
|
|
39
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalKernelRunInput, EvalLanguage } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { KernelLifecycle } from "./kernel-death.ts";
|
|
5
6
|
import type { FootprintReader } from "./kernel-memory-host.ts";
|
|
6
7
|
import type { SubprocessSpawn } from "./subprocess-process.ts";
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ export interface SubprocessKernelMemory {
|
|
|
16
17
|
readonly readFootprint: FootprintReader;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export interface SubprocessKernelOptions {
|
|
20
|
+
export interface SubprocessKernelOptions extends KernelLifecycle {
|
|
20
21
|
readonly command: string;
|
|
21
22
|
readonly args: readonly string[];
|
|
22
23
|
readonly cwd?: string;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
-
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
3
|
+
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
4
4
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
5
5
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
6
6
|
import { applySessionEnvironment } from "../session-env.ts";
|
|
7
|
+
import { describeExit } from "./kernel-death.ts";
|
|
7
8
|
import { KernelMemoryHost } from "./kernel-memory-host.ts";
|
|
8
9
|
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
9
10
|
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
KernelRetirementError,
|
|
20
21
|
KernelStartupError,
|
|
21
22
|
type PendingRun,
|
|
23
|
+
settlePendingRun,
|
|
22
24
|
timeoutResult,
|
|
23
25
|
} from "./subprocess-run.ts";
|
|
24
26
|
|
|
@@ -43,6 +45,7 @@ export class SubprocessKernel {
|
|
|
43
45
|
private retirementFailure: Error | null = null;
|
|
44
46
|
private failure: Error | null = null;
|
|
45
47
|
private closed = false;
|
|
48
|
+
private dead = false;
|
|
46
49
|
|
|
47
50
|
constructor(options: SubprocessKernelOptions) {
|
|
48
51
|
this.options = options;
|
|
@@ -52,6 +55,14 @@ export class SubprocessKernel {
|
|
|
52
55
|
this.spawnProcess();
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
isAlive(): boolean {
|
|
59
|
+
return !this.dead;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
drainPending(): readonly PendingCell[] {
|
|
63
|
+
return this.runs.drain().map((run) => ({ input: run.input, settle: (result) => settlePendingRun(run, result) }));
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
run(input: KernelRunInput): Promise<KernelResult> {
|
|
56
67
|
if (this.closed) throw new KernelClosedError();
|
|
57
68
|
const run = this.runs.enqueue(input);
|
|
@@ -243,7 +254,27 @@ export class SubprocessKernel {
|
|
|
243
254
|
}
|
|
244
255
|
this.process = null;
|
|
245
256
|
this.processReady = false;
|
|
246
|
-
|
|
257
|
+
const error = new KernelExitedError(signal ?? code ?? "unknown");
|
|
258
|
+
if (this.options.onDeath) this.die(error, describeExit(code, signal), this.options.onDeath);
|
|
259
|
+
else this.failClosed(error);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The interpreter exited on its own and is gone: the running cell fails once (its side effects may
|
|
264
|
+
* already have happened), the cells that never started stay queued for `drainPending`, and the owner
|
|
265
|
+
* replaces this instance. `run()` is never reached on it again.
|
|
266
|
+
*/
|
|
267
|
+
private die(error: Error, reason: string, onDeath: (reason: string) => void): void {
|
|
268
|
+
this.failure = error;
|
|
269
|
+
this.closed = true;
|
|
270
|
+
this.dead = true;
|
|
271
|
+
this.runs.clearToolCalls();
|
|
272
|
+
const active = this.runs.active;
|
|
273
|
+
if (active) {
|
|
274
|
+
this.runs.releaseActive(active);
|
|
275
|
+
this.runs.settle(active, failureResult(active, new Error(`${error.message}; every global is lost`)));
|
|
276
|
+
}
|
|
277
|
+
onDeath(reason);
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
private accepts(process: SubprocessProcess): boolean {
|
|
@@ -44,6 +44,11 @@ export class SubprocessRunQueue {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Removes every queued run that has not started, oldest first. */
|
|
48
|
+
drain(): PendingRun[] {
|
|
49
|
+
return this.#queue.splice(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
releaseActive(run: PendingRun): boolean {
|
|
48
53
|
if (this.#active !== run) return false;
|
|
49
54
|
this.#active = null;
|
package/src/tool/cell-runtime.ts
CHANGED
|
@@ -5,7 +5,14 @@ import { formatModelTruncationNotice } from "../output/output-meta.ts";
|
|
|
5
5
|
import { DEFAULT_MAX_BYTES, TailLineRing } from "../output/streaming-output.ts";
|
|
6
6
|
import type { EvalToolCallMetric } from "./call-capture.ts";
|
|
7
7
|
import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
EvalKernelState,
|
|
10
|
+
EvalMemoryDetails,
|
|
11
|
+
EvalRuntimeInfo,
|
|
12
|
+
EvalStatusEvent,
|
|
13
|
+
EvalToolDetails,
|
|
14
|
+
EvalToolInput,
|
|
15
|
+
} from "./types.ts";
|
|
9
16
|
|
|
10
17
|
const LIVE_UPDATE_LINES = 8;
|
|
11
18
|
/** Same cadence as the core bash tool's streaming updates (BASH_UPDATE_THROTTLE_MS). */
|
|
@@ -48,6 +55,8 @@ export class CellResultBuilder {
|
|
|
48
55
|
readonly #output: EvalOutputCollector;
|
|
49
56
|
readonly #state: CellState;
|
|
50
57
|
#memory: KernelMemoryReport | undefined;
|
|
58
|
+
#restartNotice: string | undefined;
|
|
59
|
+
#kernelState: EvalKernelState | undefined;
|
|
51
60
|
readonly #liveLines = new TailLineRing({ maxBytes: DEFAULT_MAX_BYTES * 2, maxLines: LIVE_UPDATE_LINES });
|
|
52
61
|
#lastOutputUpdateAt = 0;
|
|
53
62
|
#outputUpdateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -85,6 +94,8 @@ export class CellResultBuilder {
|
|
|
85
94
|
async finalize(result: KernelResult): Promise<AgentToolResult<EvalToolDetails>> {
|
|
86
95
|
this.#state.durationMs = result.durationMs;
|
|
87
96
|
this.#memory = result.memory;
|
|
97
|
+
this.#restartNotice = result.notice;
|
|
98
|
+
this.#kernelState = result.kernelState;
|
|
88
99
|
if (result.ok) {
|
|
89
100
|
if (result.valueRepr) this.#output.pushValue(`${result.valueRepr}\n`);
|
|
90
101
|
this.#state.status = "complete";
|
|
@@ -160,9 +171,10 @@ export class CellResultBuilder {
|
|
|
160
171
|
? `(displayed ${output.images.length} image${output.images.length === 1 ? "" : "s"}; no text output)`
|
|
161
172
|
: "(no output)");
|
|
162
173
|
const text = output.meta === undefined ? shown : `${shown}\n${formatModelTruncationNotice(output.meta)}`;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
const noticeParts = [this.#restartNotice, this.#memory?.notice].flatMap((notice) =>
|
|
175
|
+
notice === undefined ? [] : [{ type: "text" as const, text: notice }],
|
|
176
|
+
);
|
|
177
|
+
return { content: [{ type: "text", text }, ...noticeParts, ...output.images], details };
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
#details(output: EvalOutputResult | undefined, isError: boolean): EvalToolDetails {
|
|
@@ -202,6 +214,7 @@ export class CellResultBuilder {
|
|
|
202
214
|
...(output?.notice === undefined ? {} : { notice: output.notice }),
|
|
203
215
|
...(output?.meta === undefined ? {} : { meta: output.meta }),
|
|
204
216
|
...(output === undefined || this.#memory === undefined ? {} : { memory: memoryDetails(this.#memory) }),
|
|
217
|
+
...(this.#kernelState === undefined ? {} : { kernelState: this.#kernelState }),
|
|
205
218
|
};
|
|
206
219
|
}
|
|
207
220
|
|
|
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
|
|
4
4
|
import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
|
|
5
|
-
import type { EvalMemoryDetails } from "./types.ts";
|
|
5
|
+
import type { EvalKernelState, EvalMemoryDetails } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
const NOTIFICATION_TAIL_BYTES = 512;
|
|
8
8
|
|
|
@@ -78,11 +78,22 @@ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
|
|
81
|
-
if (cell.state !== "cancelled")
|
|
81
|
+
if (cell.state !== "cancelled") {
|
|
82
|
+
const kernelState = cell.result.details?.kernelState;
|
|
83
|
+
return kernelState === undefined ? memoryStateNote(cell.result.details?.memory) : KERNEL_STATE_NOTES[kernelState];
|
|
84
|
+
}
|
|
82
85
|
const note = interruptionStateNote(cell.language, cell.stateRetained) ?? unknownInterruptionStateNote(cell.language);
|
|
83
86
|
return cell.interruptNote === undefined ? note : `${note} ${cell.interruptNote.trim()}`;
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
/** A kernel death decides what survived, whatever the memory report says. */
|
|
90
|
+
const KERNEL_STATE_NOTES: Readonly<Record<EvalKernelState, string>> = {
|
|
91
|
+
lost: "The kernel died while this cell ran - every global is lost; the next eval cell runs on a fresh kernel.",
|
|
92
|
+
restarted:
|
|
93
|
+
"The kernel was restarted before this cell ran - globals from earlier cells are gone; this cell's variables are available to the next eval cell.",
|
|
94
|
+
"not-run": "This cell never ran and changed no kernel state.",
|
|
95
|
+
};
|
|
96
|
+
|
|
86
97
|
export function memoryStateNote(memory: EvalMemoryDetails | undefined): string {
|
|
87
98
|
if (memory?.overCeiling === true)
|
|
88
99
|
return "Kernel memory is over its ceiling - the kernel restarts before the next eval cell and every global is lost.";
|
package/src/tool/types.ts
CHANGED
|
@@ -161,6 +161,12 @@ export interface KernelInterruptHandle {
|
|
|
161
161
|
readonly note?: string;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** An unstarted cell a dead kernel hands back, so its replacement runs it with the same input and callbacks. */
|
|
165
|
+
export interface PendingCell {
|
|
166
|
+
readonly input: EvalKernelRunInput;
|
|
167
|
+
readonly settle: (result: EvalKernelResult) => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
164
170
|
export interface EvalKernel {
|
|
165
171
|
run(input: EvalKernelRunInput): Promise<EvalKernelResult>;
|
|
166
172
|
cancelQueued(cellId: string, reason: string): boolean;
|
|
@@ -171,6 +177,10 @@ export interface EvalKernel {
|
|
|
171
177
|
close(): Promise<void>;
|
|
172
178
|
/** Names this kernel has registered; JS collides with other languages in the same session. */
|
|
173
179
|
listKernelToolNames?(): readonly string[];
|
|
180
|
+
/** False once the interpreter died on its own; the session manager then replaces the instance. */
|
|
181
|
+
isAlive?(): boolean;
|
|
182
|
+
/** Hands over, and forgets, every queued cell of a dead kernel that never started. */
|
|
183
|
+
drainPending?(): readonly PendingCell[];
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
export interface EvalKernelManager {
|
|
@@ -273,6 +283,10 @@ export interface EvalToolDetails {
|
|
|
273
283
|
readonly meta?: TruncationMeta;
|
|
274
284
|
/** Kernel memory after the cell; its notice text is delivered as its own content part. */
|
|
275
285
|
readonly memory?: EvalMemoryDetails;
|
|
286
|
+
/** What a kernel death did to this cell's state, when one did. */
|
|
287
|
+
readonly kernelState?: EvalKernelState;
|
|
276
288
|
}
|
|
277
289
|
|
|
290
|
+
export type EvalKernelState = NonNullable<EvalKernelResult["kernelState"]>;
|
|
291
|
+
|
|
278
292
|
export type EvalMemoryDetails = Omit<KernelMemoryReport, "notice">;
|