@code-yeongyu/senpi-codemode 2026.9.5 → 2026.9.6
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 +45 -0
- package/README.md +20 -4
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +73 -129
- package/src/kernels/js/interrupt-bounds.ts +66 -0
- package/src/kernels/js/run-queue.ts +18 -3
- package/src/kernels/js/worker-core.js +30 -0
- package/src/kernels/js/worker-runtime.js +16 -0
- package/src/kernels/js/worker-shell-capture.d.ts +9 -0
- package/src/kernels/js/worker-shell-capture.js +32 -6
- package/src/kernels/js/worker-slot.ts +106 -0
- package/src/kernels/js/worker-startup.ts +69 -0
- package/src/prompt/eval-prompt.ts +10 -5
- package/src/tool/cell-execution.ts +9 -11
- package/src/tool/detached-cell-contract.ts +45 -0
- package/src/tool/detached-cell-manager.ts +43 -71
- package/src/tool/detached-cell-notification.ts +4 -5
- package/src/tool/detached-cell-snapshot.ts +2 -0
- package/src/tool/detached-cell-status.ts +30 -0
- package/src/tool/detached-eval-result.ts +1 -0
- package/src/tool/detached-notification-queue.ts +2 -2
- package/src/tool/interrupt-note.ts +29 -15
- package/src/tool/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,51 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.6] - 2026-09-06
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The Bun eval description now tells the model to shell out through `Bun.$` or `Bun.spawn` and never `Bun.spawnSync`, because a synchronous child blocks the worker and a stop or timeout then loses every variable.
|
|
24
|
+
- JavaScript eval cells now interrupt cooperatively: `stop` and kernel timeouts first ask the worker to settle the cell (pending bridge `tool.*` calls are rejected, `Bun.spawn` children are killed) and keep the worker VM and its globals when the cell settles within a 2 s grace; only an unsettled cell restarts the worker.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `eval({ action: "stop" })` no longer hangs when the JavaScript worker is blocked in a synchronous call such as `Bun.spawnSync`: worker termination is bounded by a 3 s deadline, a fresh worker replaces the blocked one, and the cell output names the blocked synchronous call.
|
|
29
|
+
- `Bun.$` commands run from a JavaScript cell no longer inherit the TUI's terminal as stdin (a stdin reader such as `cat`, an ssh or git credential prompt, or a keychain prompt blocked the cell forever); the shell wrapper isolates stdin while a cell is active without changing output, exit codes, `cwd`, `env`, or explicit stdin redirects.
|
|
30
|
+
- Stop results and detached-cell completion notifications report the real interrupt outcome (variables preserved, worker restarted, or outcome unknown) instead of a hardcoded per-language note.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
## [2026.9.5-3] - 2026-09-05
|
|
35
|
+
|
|
36
|
+
### Breaking Changes
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
### Removed
|
|
45
|
+
|
|
46
|
+
## [2026.9.5-2] - 2026-09-05
|
|
47
|
+
|
|
48
|
+
### Breaking Changes
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- The GPT eval dialect now routes a wait or a long run through `tool.monitor` inside the cell (the subscription line precedes the detach note, and the `## Tool Guidelines` line says so when `monitor` is reachable), so a GPT model no longer reads "long cells detach" as the way to wait on a `--watch`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
### Removed
|
|
59
|
+
|
|
15
60
|
## [2026.9.5] - 2026-09-05
|
|
16
61
|
|
|
17
62
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -166,10 +166,26 @@ when the call had no summary), clearing as soon as the last detached cell settle
|
|
|
166
166
|
|
|
167
167
|
Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
|
|
168
168
|
`eval({ action: "stop", cell_id })` to cancel it. Python stop interrupts the
|
|
169
|
-
existing kernel and preserves variables. JavaScript stop
|
|
170
|
-
worker
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
existing kernel and preserves variables. JavaScript stop is cooperative first:
|
|
170
|
+
the worker rejects the cell's pending bridge `tool.*` calls and kills the
|
|
171
|
+
`Bun.spawn` children it started, and a cell that settles within the 2 s grace
|
|
172
|
+
keeps the worker and every global. Only a cell that stays unsettled (a
|
|
173
|
+
never-resolving promise, an un-abortable `fetch`, a `Bun.$` command) costs the
|
|
174
|
+
worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
|
|
175
|
+
`child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
|
|
176
|
+
deadline a fresh worker replaces it, the cell output gains a stderr line naming
|
|
177
|
+
the blocked synchronous call, and the blocked call keeps running until it
|
|
178
|
+
returns. Kernel-level timeouts follow the same path. Stop results and detached
|
|
179
|
+
completion messages report the real outcome - variables preserved, worker
|
|
180
|
+
restarted, or outcome unknown - never a per-language assumption; oversized
|
|
181
|
+
buffered output is written under the session local root and referenced as
|
|
182
|
+
`local://…`.
|
|
183
|
+
|
|
184
|
+
Commands a cell runs through `Bun.$` never read the host's terminal: the worker
|
|
185
|
+
thread shares the TUI's stdin, so the shell wrapper hands every template an
|
|
186
|
+
empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
|
|
187
|
+
`env`, and explicit `< ${input}` redirects are unchanged; `Bun.spawn` and
|
|
188
|
+
`Bun.spawnSync` already default stdin to `/dev/null`.
|
|
173
189
|
|
|
174
190
|
## Output and artifacts
|
|
175
191
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.6",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.6",
|
|
34
34
|
"typebox": "1.3.18"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.6"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.6"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/bridge/reserved.ts
CHANGED
|
@@ -9,3 +9,5 @@ export const RESERVED_SCHEMA_TOOL = "__schema__" as const;
|
|
|
9
9
|
export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
|
|
10
10
|
/** Canonical oh-my-pi eval-timeout resume operation. */
|
|
11
11
|
export const TIMEOUT_RESUME_OP = "timeout-resume" as const;
|
|
12
|
+
/** Status op the JS worker emits the moment it receives `interrupt`; proves its event loop is not blocked. */
|
|
13
|
+
export const INTERRUPT_ACK_OP = "interrupt-ack" as const;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { dirname, join } from "node:path";
|
|
2
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
4
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
5
|
-
import {
|
|
6
|
-
import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
|
|
4
|
+
import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
7
5
|
import {
|
|
8
6
|
assertJavaScriptKernelOpen,
|
|
9
7
|
type JavaScriptKernelMode,
|
|
@@ -12,31 +10,20 @@ import {
|
|
|
12
10
|
type ResultMessage,
|
|
13
11
|
type ToolCallMessage,
|
|
14
12
|
} from "./kernel-contract.ts";
|
|
15
|
-
import { type JavaScriptKernelOptions, LocalModuleLoader
|
|
13
|
+
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
16
14
|
import { JavaScriptRunQueue, type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts";
|
|
17
|
-
import { bridgeError,
|
|
15
|
+
import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
|
|
16
|
+
import { WorkerSlot } from "./worker-slot.ts";
|
|
18
17
|
|
|
19
18
|
export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScriptRunInput } from "./kernel-contract.ts";
|
|
20
19
|
export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
21
|
-
|
|
22
|
-
export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
23
|
-
readonly localPath?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions = {}): URL {
|
|
27
|
-
const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "worker-entry.js");
|
|
28
|
-
return pathToFileURL(resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
|
|
29
|
-
}
|
|
20
|
+
export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
|
|
30
21
|
|
|
31
22
|
export class JavaScriptKernel {
|
|
32
23
|
readonly #options: JavaScriptKernelOptions;
|
|
33
24
|
readonly #moduleLoader: LocalModuleLoader;
|
|
34
|
-
#
|
|
35
|
-
#mode: JavaScriptKernelMode = "worker";
|
|
25
|
+
readonly #slot: WorkerSlot;
|
|
36
26
|
#lifecycle: LifecycleState = "open";
|
|
37
|
-
#ready: Promise<void> | null = null;
|
|
38
|
-
#startupAbort: AbortController | null = null;
|
|
39
|
-
#generation = 0;
|
|
40
27
|
#activation: Promise<void> | null = null;
|
|
41
28
|
#recovery: Promise<void> | null = null;
|
|
42
29
|
#closePromise: Promise<void> | null = null;
|
|
@@ -48,10 +35,15 @@ export class JavaScriptKernel {
|
|
|
48
35
|
constructor(options: JavaScriptKernelOptions) {
|
|
49
36
|
this.#options = options;
|
|
50
37
|
this.#moduleLoader = new LocalModuleLoader(options);
|
|
38
|
+
this.#slot = new WorkerSlot(options, {
|
|
39
|
+
isOpen: () => this.#lifecycle === "open",
|
|
40
|
+
onMessage: (message) => this.#handleMessage(message),
|
|
41
|
+
onCrash: (error) => this.#handleCrash(error),
|
|
42
|
+
});
|
|
51
43
|
}
|
|
52
44
|
|
|
53
45
|
get mode(): JavaScriptKernelMode {
|
|
54
|
-
return this.#mode;
|
|
46
|
+
return this.#slot.mode;
|
|
55
47
|
}
|
|
56
48
|
|
|
57
49
|
async run(input: JavaScriptRunInput): Promise<ResultMessage> {
|
|
@@ -64,13 +56,16 @@ export class JavaScriptKernel {
|
|
|
64
56
|
async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
|
|
65
57
|
assertJavaScriptKernelOpen(this.#lifecycle, "interrupt");
|
|
66
58
|
const active = this.#runs.active;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
if (!active) {
|
|
60
|
+
const queued = this.#runs.takeInterruptTarget();
|
|
61
|
+
if (!queued) return { stateRetained: Promise.resolve(true) };
|
|
62
|
+
this.#runs.settle(queued, stoppedResult(queued.input.cellId, `JS cell interrupted: ${reason}`));
|
|
63
|
+
await this.#restartAfterStop();
|
|
64
|
+
return { stateRetained: Promise.resolve(false) };
|
|
65
|
+
}
|
|
66
|
+
this.#clearTimeout();
|
|
67
|
+
const stop = await this.#stopActive(active, reason, `JS cell interrupted: ${reason}`);
|
|
68
|
+
return { stateRetained: Promise.resolve(stop.retained), ...(stop.note === undefined ? {} : { note: stop.note }) };
|
|
74
69
|
}
|
|
75
70
|
|
|
76
71
|
async reset(): Promise<void> {
|
|
@@ -82,7 +77,7 @@ export class JavaScriptKernel {
|
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
|
|
85
|
-
if (this.#lifecycle === "open") this.#
|
|
80
|
+
if (this.#lifecycle === "open") this.#slot.postMessage(message);
|
|
86
81
|
}
|
|
87
82
|
|
|
88
83
|
async nextToolCall(): Promise<ToolCallMessage> {
|
|
@@ -93,7 +88,7 @@ export class JavaScriptKernel {
|
|
|
93
88
|
|
|
94
89
|
async close(): Promise<void> {
|
|
95
90
|
if (this.#closePromise) return await this.#closePromise;
|
|
96
|
-
this.#
|
|
91
|
+
this.#slot.postMessage({ type: "close" });
|
|
97
92
|
this.#lifecycle = "closing";
|
|
98
93
|
this.#runs.settleAll("JS kernel closed");
|
|
99
94
|
const recovery = this.#recovery;
|
|
@@ -129,92 +124,17 @@ export class JavaScriptKernel {
|
|
|
129
124
|
|
|
130
125
|
async #ensureReady(): Promise<void> {
|
|
131
126
|
assertJavaScriptKernelOpen(this.#lifecycle, "run");
|
|
132
|
-
|
|
133
|
-
const generation = ++this.#generation;
|
|
134
|
-
const controller = new AbortController();
|
|
135
|
-
this.#startupAbort = controller;
|
|
136
|
-
const ready = this.#startWorker(generation, controller.signal);
|
|
137
|
-
this.#ready = ready;
|
|
138
|
-
void ready.then(
|
|
139
|
-
() => {
|
|
140
|
-
if (this.#ready === ready) this.#startupAbort = null;
|
|
141
|
-
},
|
|
142
|
-
() => {
|
|
143
|
-
if (this.#ready === ready) {
|
|
144
|
-
this.#ready = null;
|
|
145
|
-
this.#startupAbort = null;
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
return await this.#ready;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async #startWorker(generation: number, signal: AbortSignal): Promise<void> {
|
|
154
|
-
let worker = this.#spawnWorker();
|
|
155
|
-
this.#publishWorker(worker, generation);
|
|
156
|
-
try {
|
|
157
|
-
await this.#initializeWorker(worker, signal);
|
|
158
|
-
return;
|
|
159
|
-
} catch (error) {
|
|
160
|
-
if (!this.#isCurrent(worker, generation) || error instanceof WorkerStartupCancelledError) {
|
|
161
|
-
await worker.terminate();
|
|
162
|
-
throw new WorkerStartupCancelledError();
|
|
163
|
-
}
|
|
164
|
-
if (worker.mode === "inline") throw error;
|
|
165
|
-
this.#worker = null;
|
|
166
|
-
await worker.terminate();
|
|
167
|
-
}
|
|
168
|
-
if (this.#lifecycle !== "open" || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
169
|
-
worker = createInlineWorker(this.#options.cwd, this.#options.parallelPoolWidth);
|
|
170
|
-
this.#publishWorker(worker, generation);
|
|
171
|
-
await this.#initializeWorker(worker, signal);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
#spawnWorker(): WorkerLike {
|
|
175
|
-
try {
|
|
176
|
-
const url = this.#options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
|
|
177
|
-
return spawnNodeWorker(url, this.#options.cwd, this.#options.parallelPoolWidth);
|
|
178
|
-
} catch (error) {
|
|
179
|
-
if (!(error instanceof Error)) throw error;
|
|
180
|
-
return createInlineWorker(this.#options.cwd, this.#options.parallelPoolWidth);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
#publishWorker(worker: WorkerLike, generation: number): void {
|
|
185
|
-
if (this.#lifecycle !== "open" || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
186
|
-
this.#worker = worker;
|
|
187
|
-
this.#mode = worker.mode;
|
|
188
|
-
worker.onMessage((message) => {
|
|
189
|
-
if (this.#isCurrent(worker, generation)) this.#handleMessage(message);
|
|
190
|
-
});
|
|
191
|
-
worker.onError((error) => {
|
|
192
|
-
if (this.#isCurrent(worker, generation)) this.#handleCrash(error);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async #initializeWorker(worker: WorkerLike, signal: AbortSignal): Promise<void> {
|
|
197
|
-
const ready = waitForReady(worker, signal);
|
|
198
|
-
worker.postMessage({
|
|
199
|
-
type: "init",
|
|
200
|
-
sessionId: this.#options.sessionId,
|
|
201
|
-
connection: localBridgeConnection(this.#options),
|
|
202
|
-
});
|
|
203
|
-
await ready;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
#isCurrent(worker: WorkerLike, generation: number): boolean {
|
|
207
|
-
return this.#lifecycle === "open" && this.#worker === worker && this.#generation === generation;
|
|
127
|
+
await this.#slot.ensureReady();
|
|
208
128
|
}
|
|
209
129
|
|
|
210
130
|
#startNext(): void {
|
|
211
|
-
if (this.#lifecycle !== "open" || this.#runs.active || !this.#
|
|
131
|
+
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
212
132
|
const next = this.#runs.startNext(performance.now());
|
|
213
133
|
if (!next) return;
|
|
214
134
|
if (next.input.timeoutMs) {
|
|
215
135
|
this.#timeout = setTimeout(() => void this.#timeoutActive(next), next.input.timeoutMs);
|
|
216
136
|
}
|
|
217
|
-
this.#
|
|
137
|
+
this.#slot.postMessage({
|
|
218
138
|
type: "run",
|
|
219
139
|
cellId: next.input.cellId,
|
|
220
140
|
code: this.#moduleLoader.prepareCell(next.input.code),
|
|
@@ -223,21 +143,46 @@ export class JavaScriptKernel {
|
|
|
223
143
|
}
|
|
224
144
|
|
|
225
145
|
async #timeoutActive(run: PendingJavaScriptRun): Promise<void> {
|
|
226
|
-
if (
|
|
146
|
+
if (this.#runs.active !== run || run.settled) return;
|
|
227
147
|
const durationMs = run.input.timeoutMs ?? 0;
|
|
228
|
-
this.#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
error: { message: `JS cell timed out after ${durationMs}ms` },
|
|
148
|
+
await this.#stopActive(
|
|
149
|
+
run,
|
|
150
|
+
`timed out after ${durationMs}ms`,
|
|
151
|
+
`JS cell timed out after ${durationMs}ms`,
|
|
233
152
|
durationMs,
|
|
234
|
-
|
|
235
|
-
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Asks the worker to settle the active cell cooperatively (rejecting its bridge calls and killing its
|
|
158
|
+
* children); only a cell that stays unsettled past the grace costs the worker VM. Reports whether the
|
|
159
|
+
* worker state survived and, when a blocked worker had to be abandoned, the note that explains it.
|
|
160
|
+
*/
|
|
161
|
+
async #stopActive(
|
|
162
|
+
run: PendingJavaScriptRun,
|
|
163
|
+
reason: string,
|
|
164
|
+
message: string,
|
|
165
|
+
durationMs = 0,
|
|
166
|
+
): Promise<{ readonly retained: boolean; readonly note?: string }> {
|
|
167
|
+
run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
|
|
168
|
+
run.interruptAck ??= Promise.withResolvers<void>();
|
|
169
|
+
this.#slot.postMessage({ type: "interrupt", reason });
|
|
170
|
+
if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
|
|
171
|
+
if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
|
|
172
|
+
const retirement = await this.#terminate();
|
|
173
|
+
this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
|
|
174
|
+
void this.#recover(() => Promise.resolve());
|
|
175
|
+
return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
|
|
236
176
|
}
|
|
237
177
|
|
|
238
178
|
async #restartAfterStop(): Promise<void> {
|
|
179
|
+
await this.#recover(() => this.#terminate());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One recovery at a time: retire through `retire` (a no-op when the worker is already gone), then bring a fresh worker up. */
|
|
183
|
+
async #recover(retire: () => Promise<unknown>): Promise<void> {
|
|
239
184
|
if (this.#recovery) return await this.#recovery;
|
|
240
|
-
const recovery = this.#
|
|
185
|
+
const recovery = this.#performRecovery(retire);
|
|
241
186
|
this.#recovery = recovery;
|
|
242
187
|
try {
|
|
243
188
|
await recovery;
|
|
@@ -246,9 +191,9 @@ export class JavaScriptKernel {
|
|
|
246
191
|
}
|
|
247
192
|
}
|
|
248
193
|
|
|
249
|
-
async #
|
|
194
|
+
async #performRecovery(retire: () => Promise<unknown>): Promise<void> {
|
|
250
195
|
try {
|
|
251
|
-
await
|
|
196
|
+
await retire();
|
|
252
197
|
if (this.#lifecycle !== "open") return;
|
|
253
198
|
await this.#ensureReady();
|
|
254
199
|
if (this.#lifecycle === "open") this.#startNext();
|
|
@@ -259,6 +204,10 @@ export class JavaScriptKernel {
|
|
|
259
204
|
}
|
|
260
205
|
|
|
261
206
|
#handleMessage(message: KernelToHostMessage): void {
|
|
207
|
+
if (message.type === "status" && message.event.op === INTERRUPT_ACK_OP) {
|
|
208
|
+
this.#runs.active?.interruptAck?.resolve();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
262
211
|
this.#options.onMessage?.(message);
|
|
263
212
|
this.#runs.active?.input.onMessage?.(message);
|
|
264
213
|
if (message.type === "tool-call") {
|
|
@@ -272,13 +221,14 @@ export class JavaScriptKernel {
|
|
|
272
221
|
if (!active || active.input.cellId !== message.cellId) return;
|
|
273
222
|
this.#clearTimeout();
|
|
274
223
|
this.#runs.releaseActive(active);
|
|
275
|
-
|
|
224
|
+
active.settledByWorker = true;
|
|
225
|
+
this.#runs.settle(active, active.interruptResult ?? message);
|
|
276
226
|
this.#startNext();
|
|
277
227
|
}
|
|
278
228
|
|
|
279
229
|
#handleCrash(error: Error): void {
|
|
280
230
|
const active = this.#runs.active;
|
|
281
|
-
if (!active && this.#
|
|
231
|
+
if (!active && this.#slot.startingUp) return;
|
|
282
232
|
this.#clearTimeout();
|
|
283
233
|
if (active) {
|
|
284
234
|
this.#runs.releaseActive(active);
|
|
@@ -298,14 +248,8 @@ export class JavaScriptKernel {
|
|
|
298
248
|
this.#timeout = null;
|
|
299
249
|
}
|
|
300
250
|
|
|
301
|
-
async #terminate(): Promise<
|
|
251
|
+
async #terminate(): Promise<WorkerRetirement> {
|
|
302
252
|
this.#clearTimeout();
|
|
303
|
-
this.#
|
|
304
|
-
this.#startupAbort?.abort();
|
|
305
|
-
this.#startupAbort = null;
|
|
306
|
-
this.#ready = null;
|
|
307
|
-
const worker = this.#worker;
|
|
308
|
-
this.#worker = null;
|
|
309
|
-
if (worker) await worker.terminate();
|
|
253
|
+
return await this.#slot.retire();
|
|
310
254
|
}
|
|
311
255
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { WorkerLike } from "./inline-worker.ts";
|
|
2
|
+
import type { PendingJavaScriptRun } from "./run-queue.ts";
|
|
3
|
+
|
|
4
|
+
/** How long the worker gets to acknowledge `interrupt`; silence means its event loop is blocked in synchronous code. */
|
|
5
|
+
export const INTERRUPT_ACK_MS = 500;
|
|
6
|
+
/** How long an acknowledged cell gets to settle before the VM is replaced. */
|
|
7
|
+
export const JS_INTERRUPT_GRACE_MS = 2_000;
|
|
8
|
+
/** How long `worker.terminate()` may take before the worker is abandoned as blocked in a synchronous call. */
|
|
9
|
+
export const WORKER_TERMINATE_DEADLINE_MS = 3_000;
|
|
10
|
+
|
|
11
|
+
export type CooperativeSettlement = "settled" | "unresponsive";
|
|
12
|
+
export type WorkerRetirement = "terminated" | "abandoned";
|
|
13
|
+
|
|
14
|
+
export interface CooperativeSettlementBounds {
|
|
15
|
+
readonly ackMs: number;
|
|
16
|
+
readonly graceMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_SETTLEMENT_BOUNDS: CooperativeSettlementBounds = {
|
|
20
|
+
ackMs: INTERRUPT_ACK_MS,
|
|
21
|
+
graceMs: JS_INTERRUPT_GRACE_MS,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function awaitCooperativeSettlement(
|
|
25
|
+
run: PendingJavaScriptRun,
|
|
26
|
+
bounds = DEFAULT_SETTLEMENT_BOUNDS,
|
|
27
|
+
): Promise<CooperativeSettlement> {
|
|
28
|
+
if (run.settled) return "settled";
|
|
29
|
+
const settled = run.settlement.then((): "settled" => "settled");
|
|
30
|
+
const acked = run.interruptAck?.promise.then((): "acked" => "acked") ?? Promise.resolve<"acked">("acked");
|
|
31
|
+
const first = await raceDeadline(Promise.race([settled, acked]), bounds.ackMs, "unresponsive");
|
|
32
|
+
if (first !== "acked") return first;
|
|
33
|
+
return await raceDeadline(settled, bounds.graceMs, "unresponsive");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function retireWorker(
|
|
37
|
+
worker: WorkerLike,
|
|
38
|
+
deadlineMs = WORKER_TERMINATE_DEADLINE_MS,
|
|
39
|
+
): Promise<WorkerRetirement> {
|
|
40
|
+
const termination = worker.terminate().then((): WorkerRetirement => "terminated");
|
|
41
|
+
const outcome = await raceDeadline(termination, deadlineMs, "abandoned");
|
|
42
|
+
if (outcome === "abandoned") void termination.then(undefined, ignoreLateTerminationFailure);
|
|
43
|
+
return outcome;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function abandonedWorkerNote(deadlineMs = WORKER_TERMINATE_DEADLINE_MS): string {
|
|
47
|
+
return `JavaScript worker did not stop within ${deadlineMs}ms: a synchronous call (for example Bun.spawnSync or child_process.spawnSync) is blocking it. A fresh worker replaced it; the blocked call keeps running until it returns.\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function raceDeadline<T extends string, Fallback extends string>(
|
|
51
|
+
operation: Promise<T>,
|
|
52
|
+
deadlineMs: number,
|
|
53
|
+
fallback: Fallback,
|
|
54
|
+
): Promise<T | Fallback> {
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
const deadline = new Promise<Fallback>((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve(fallback), deadlineMs);
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
return await Promise.race([operation, deadline]);
|
|
61
|
+
} finally {
|
|
62
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ignoreLateTerminationFailure(): void {}
|
|
@@ -7,8 +7,13 @@ export interface PendingJavaScriptRun {
|
|
|
7
7
|
readonly input: JavaScriptRunInput;
|
|
8
8
|
readonly resolve: (message: ResultMessage) => void;
|
|
9
9
|
readonly reject: (error: Error) => void;
|
|
10
|
+
readonly settlement: Promise<ResultMessage>;
|
|
10
11
|
startedAtMs: number | null;
|
|
11
12
|
settled: boolean;
|
|
13
|
+
/** Host-composed result that wins over whatever the worker reports once an interrupt is in flight. */
|
|
14
|
+
interruptResult: ResultMessage | null;
|
|
15
|
+
interruptAck: PromiseWithResolvers<void> | null;
|
|
16
|
+
settledByWorker: boolean;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export class JavaScriptRunQueue {
|
|
@@ -24,9 +29,19 @@ export class JavaScriptRunQueue {
|
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
enqueue(input: JavaScriptRunInput): Promise<ResultMessage> {
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
const { promise, resolve, reject } = Promise.withResolvers<ResultMessage>();
|
|
33
|
+
this.#queue.push({
|
|
34
|
+
input,
|
|
35
|
+
resolve,
|
|
36
|
+
reject,
|
|
37
|
+
settlement: promise,
|
|
38
|
+
startedAtMs: null,
|
|
39
|
+
settled: false,
|
|
40
|
+
interruptResult: null,
|
|
41
|
+
interruptAck: null,
|
|
42
|
+
settledByWorker: false,
|
|
29
43
|
});
|
|
44
|
+
return promise;
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
startNext(startedAtMs: number): PendingJavaScriptRun | null {
|
|
@@ -64,7 +79,7 @@ export class JavaScriptRunQueue {
|
|
|
64
79
|
settleAll(message: string): void {
|
|
65
80
|
const active = this.#active;
|
|
66
81
|
this.#active = null;
|
|
67
|
-
if (active) this.settle(active, stoppedResult(active.input.cellId, message));
|
|
82
|
+
if (active) this.settle(active, active.interruptResult ?? stoppedResult(active.input.cellId, message));
|
|
68
83
|
for (const queued of this.#queue.splice(0)) this.settle(queued, stoppedResult(queued.input.cellId, message));
|
|
69
84
|
}
|
|
70
85
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
2
2
|
|
|
3
|
+
// Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
|
|
4
|
+
const INTERRUPT_ACK_OP = "interrupt-ack";
|
|
5
|
+
|
|
3
6
|
export function createWorkerCore(transport, options) {
|
|
4
7
|
let runtime = null;
|
|
8
|
+
let activeCell = null;
|
|
5
9
|
const pendingTools = new Map();
|
|
6
10
|
|
|
7
11
|
function emit(message) {
|
|
@@ -14,6 +18,7 @@ export function createWorkerCore(transport, options) {
|
|
|
14
18
|
return;
|
|
15
19
|
}
|
|
16
20
|
const startedAtMs = performance.now();
|
|
21
|
+
activeCell = { cellId: message.cellId, interruption: null };
|
|
17
22
|
try {
|
|
18
23
|
const value = await runtime.run(message.code, message.cellId, {
|
|
19
24
|
emit,
|
|
@@ -22,16 +27,31 @@ export function createWorkerCore(transport, options) {
|
|
|
22
27
|
emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs) });
|
|
23
28
|
} catch (error) {
|
|
24
29
|
emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs) });
|
|
30
|
+
} finally {
|
|
31
|
+
activeCell = null;
|
|
25
32
|
}
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
async function callTool(toolName, args) {
|
|
36
|
+
if (activeCell?.interruption) throw activeCell.interruption;
|
|
29
37
|
const callId = `js-${crypto.randomUUID()}`;
|
|
30
38
|
const promise = new Promise((resolve, reject) => pendingTools.set(callId, { resolve, reject }));
|
|
31
39
|
emit({ type: "tool-call", callId, toolName, args });
|
|
32
40
|
return await promise;
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
function interruptCell(reason) {
|
|
44
|
+
if (!activeCell || !runtime) return;
|
|
45
|
+
emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
|
|
46
|
+
const interruption = cellInterruptedError(reason);
|
|
47
|
+
activeCell.interruption = interruption;
|
|
48
|
+
for (const [callId, pending] of pendingTools) {
|
|
49
|
+
pendingTools.delete(callId);
|
|
50
|
+
pending.reject(interruption);
|
|
51
|
+
}
|
|
52
|
+
runtime.interrupt();
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
function onMessage(message) {
|
|
36
56
|
if (message.type === "init") {
|
|
37
57
|
runtime = new JsWorkerRuntime({
|
|
@@ -55,6 +75,10 @@ export function createWorkerCore(transport, options) {
|
|
|
55
75
|
else pending.reject(errorFromBridge(message.error));
|
|
56
76
|
return;
|
|
57
77
|
}
|
|
78
|
+
if (message.type === "interrupt") {
|
|
79
|
+
interruptCell(message.reason ?? "interrupted");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
58
82
|
if (message.type === "close") {
|
|
59
83
|
emit({ type: "closed" });
|
|
60
84
|
transport.close();
|
|
@@ -79,6 +103,12 @@ function valueRepr(value) {
|
|
|
79
103
|
return JSON.stringify(value);
|
|
80
104
|
}
|
|
81
105
|
|
|
106
|
+
function cellInterruptedError(reason) {
|
|
107
|
+
const error = new Error(`JS cell interrupted: ${reason}`);
|
|
108
|
+
error.name = "CellInterruptedError";
|
|
109
|
+
return error;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
function bridgeError(error) {
|
|
83
113
|
if (error instanceof Error) {
|
|
84
114
|
return { name: error.name, message: error.message, stack: error.stack };
|
|
@@ -16,6 +16,7 @@ export class JsWorkerRuntime {
|
|
|
16
16
|
#env = new Map();
|
|
17
17
|
#hooks = null;
|
|
18
18
|
#pendingDisplays = [];
|
|
19
|
+
#children = new Set();
|
|
19
20
|
|
|
20
21
|
constructor(options) {
|
|
21
22
|
this.#cwd = options.cwd;
|
|
@@ -41,10 +42,24 @@ export class JsWorkerRuntime {
|
|
|
41
42
|
return value;
|
|
42
43
|
} finally {
|
|
43
44
|
this.#pendingDisplays = [];
|
|
45
|
+
this.#children.clear();
|
|
44
46
|
this.#hooks = null;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
interrupt() {
|
|
51
|
+
for (const child of this.#children) {
|
|
52
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#trackChild(child) {
|
|
57
|
+
if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
|
|
58
|
+
this.#children.add(child);
|
|
59
|
+
const forget = () => this.#children.delete(child);
|
|
60
|
+
if (child.exited instanceof Promise) child.exited.then(forget, forget);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
async #drainPendingDisplays() {
|
|
49
64
|
while (this.#pendingDisplays.length > 0) {
|
|
50
65
|
const pending = this.#pendingDisplays;
|
|
@@ -99,6 +114,7 @@ export class JsWorkerRuntime {
|
|
|
99
114
|
const restoreShellCapture = installShellCapture({
|
|
100
115
|
isActive: () => this.#hooks !== null,
|
|
101
116
|
emitText: (stream, data) => this.#emitText(stream, data),
|
|
117
|
+
onChild: (child) => this.#trackChild(child),
|
|
102
118
|
});
|
|
103
119
|
globalThis.__senpi_restore_console__ = () => {
|
|
104
120
|
console.log = originalLog;
|
|
@@ -2,9 +2,18 @@ export type ShellCaptureStream = "stdout" | "stderr";
|
|
|
2
2
|
|
|
3
3
|
export type ShellCaptureRestore = () => void;
|
|
4
4
|
|
|
5
|
+
export interface ShellCaptureChild {
|
|
6
|
+
readonly exitCode: number | null;
|
|
7
|
+
readonly signalCode: string | null;
|
|
8
|
+
readonly exited: Promise<number>;
|
|
9
|
+
kill(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export interface ShellCaptureOptions {
|
|
6
13
|
readonly isActive: () => boolean;
|
|
7
14
|
readonly emitText: (stream: ShellCaptureStream, data: string) => void;
|
|
15
|
+
/** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
|
|
16
|
+
readonly onChild?: (child: ShellCaptureChild) => void;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
|