@code-yeongyu/senpi-codemode 2026.9.5-3 → 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 +19 -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 +1 -1
- 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
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
|
|
2
2
|
const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
|
|
3
|
+
// `true | ( … )` hands every command in the template an empty pipe as stdin. The worker thread shares
|
|
4
|
+
// the host process's fd 0 (the TUI's terminal), which Bun.$ would otherwise inherit, so a stdin
|
|
5
|
+
// reader would wait on the user's keyboard forever. The newline before `)` keeps a trailing comment
|
|
6
|
+
// from swallowing the closing paren; the Bun shell has no other stdin control (no `$.stdin`, no
|
|
7
|
+
// redirect on a subshell).
|
|
8
|
+
const STDIN_ISOLATION_HEAD = "true | (\n";
|
|
9
|
+
const STDIN_ISOLATION_TAIL = "\n)";
|
|
3
10
|
|
|
4
11
|
export function installShellCapture(options) {
|
|
5
12
|
const bun = globalThis.Bun;
|
|
@@ -20,8 +27,9 @@ function isBunRuntime(bun) {
|
|
|
20
27
|
|
|
21
28
|
function capturedShell(originalShell, options) {
|
|
22
29
|
const shell = (strings, ...expressions) => {
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
if (!options.isActive()) return originalShell(strings, ...expressions);
|
|
31
|
+
const promise = originalShell(isolateStdin(strings), ...expressions);
|
|
32
|
+
return captureShellPromise(promise, options.emitText);
|
|
25
33
|
};
|
|
26
34
|
for (const key of Object.keys(originalShell)) shell[key] = originalShell[key];
|
|
27
35
|
for (const method of SHELL_CONFIG_METHODS) {
|
|
@@ -33,6 +41,18 @@ function capturedShell(originalShell, options) {
|
|
|
33
41
|
return shell;
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
function isolateStdin(strings) {
|
|
45
|
+
if (!Array.isArray(strings) || !Array.isArray(strings.raw)) return strings;
|
|
46
|
+
const cooked = [...strings];
|
|
47
|
+
const raw = [...strings.raw];
|
|
48
|
+
const last = cooked.length - 1;
|
|
49
|
+
cooked[0] = `${STDIN_ISOLATION_HEAD}${cooked[0]}`;
|
|
50
|
+
raw[0] = `${STDIN_ISOLATION_HEAD}${raw[0]}`;
|
|
51
|
+
cooked[last] = `${cooked[last]}${STDIN_ISOLATION_TAIL}`;
|
|
52
|
+
raw[last] = `${raw[last]}${STDIN_ISOLATION_TAIL}`;
|
|
53
|
+
return Object.freeze(Object.assign(cooked, { raw: Object.freeze(raw) }));
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
function captureShellPromise(promise, emitText) {
|
|
37
57
|
const prototype = Object.getPrototypeOf(promise);
|
|
38
58
|
let echo = true;
|
|
@@ -87,13 +107,19 @@ function capturedSpawn(originalSpawn, options) {
|
|
|
87
107
|
return (...args) => {
|
|
88
108
|
if (!options.isActive()) return originalSpawn(...args);
|
|
89
109
|
const [first, second] = args;
|
|
110
|
+
let child;
|
|
90
111
|
if (Array.isArray(first)) {
|
|
91
112
|
const spawnOptions = second === undefined ? {} : second;
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
child = needsStderrCapture(spawnOptions)
|
|
114
|
+
? drainStderr(originalSpawn(first, { ...spawnOptions, stderr: "pipe" }), options.emitText)
|
|
115
|
+
: originalSpawn(...args);
|
|
116
|
+
} else {
|
|
117
|
+
child = needsStderrCapture(first)
|
|
118
|
+
? drainStderr(originalSpawn({ ...first, stderr: "pipe" }), options.emitText)
|
|
119
|
+
: originalSpawn(...args);
|
|
94
120
|
}
|
|
95
|
-
|
|
96
|
-
return
|
|
121
|
+
options.onChild?.(child);
|
|
122
|
+
return child;
|
|
97
123
|
};
|
|
98
124
|
}
|
|
99
125
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { WorkerLike } from "./inline-worker.ts";
|
|
3
|
+
import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
4
|
+
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
5
|
+
import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
6
|
+
import { WorkerStartupCancelledError } from "./worker-host.ts";
|
|
7
|
+
import { startWorkerWithInlineFallback } from "./worker-startup.ts";
|
|
8
|
+
|
|
9
|
+
export interface WorkerSlotListeners {
|
|
10
|
+
isOpen(): boolean;
|
|
11
|
+
onMessage(message: KernelToHostMessage): void;
|
|
12
|
+
onCrash(error: Error): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The kernel's current worker generation: startup with inline fallback, message fencing, bounded retirement. */
|
|
16
|
+
export class WorkerSlot {
|
|
17
|
+
readonly #options: JavaScriptKernelOptions;
|
|
18
|
+
readonly #listeners: WorkerSlotListeners;
|
|
19
|
+
#worker: WorkerLike | null = null;
|
|
20
|
+
#mode: JavaScriptKernelMode = "worker";
|
|
21
|
+
#generation = 0;
|
|
22
|
+
#ready: Promise<void> | null = null;
|
|
23
|
+
#startupAbort: AbortController | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(options: JavaScriptKernelOptions, listeners: WorkerSlotListeners) {
|
|
26
|
+
this.#options = options;
|
|
27
|
+
this.#listeners = listeners;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get mode(): JavaScriptKernelMode {
|
|
31
|
+
return this.#mode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get present(): boolean {
|
|
35
|
+
return this.#worker !== null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get startingUp(): boolean {
|
|
39
|
+
return this.#startupAbort !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
postMessage(message: HostToKernelMessage): void {
|
|
43
|
+
this.#worker?.postMessage(message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async ensureReady(): Promise<void> {
|
|
47
|
+
if (!this.#ready) {
|
|
48
|
+
const generation = ++this.#generation;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
this.#startupAbort = controller;
|
|
51
|
+
const ready = startWorkerWithInlineFallback(
|
|
52
|
+
{
|
|
53
|
+
options: this.#options,
|
|
54
|
+
publish: (worker) => this.#publish(worker, generation),
|
|
55
|
+
isCurrent: (worker) => this.#isCurrent(worker, generation),
|
|
56
|
+
retire: (worker) => {
|
|
57
|
+
if (this.#worker === worker) this.#worker = null;
|
|
58
|
+
},
|
|
59
|
+
canFallBackInline: () => this.#listeners.isOpen() && generation === this.#generation,
|
|
60
|
+
},
|
|
61
|
+
controller.signal,
|
|
62
|
+
);
|
|
63
|
+
this.#ready = ready;
|
|
64
|
+
void ready.then(
|
|
65
|
+
() => {
|
|
66
|
+
if (this.#ready !== ready) return;
|
|
67
|
+
this.#startupAbort = null;
|
|
68
|
+
this.#mode = this.#worker?.mode ?? this.#mode;
|
|
69
|
+
},
|
|
70
|
+
() => {
|
|
71
|
+
if (this.#ready === ready) {
|
|
72
|
+
this.#ready = null;
|
|
73
|
+
this.#startupAbort = null;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return await this.#ready;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async retire(): Promise<WorkerRetirement> {
|
|
82
|
+
this.#generation += 1;
|
|
83
|
+
this.#startupAbort?.abort();
|
|
84
|
+
this.#startupAbort = null;
|
|
85
|
+
this.#ready = null;
|
|
86
|
+
const worker = this.#worker;
|
|
87
|
+
this.#worker = null;
|
|
88
|
+
if (!worker) return "terminated";
|
|
89
|
+
return await retireWorker(worker);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#publish(worker: WorkerLike, generation: number): void {
|
|
93
|
+
if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
94
|
+
this.#worker = worker;
|
|
95
|
+
worker.onMessage((message) => {
|
|
96
|
+
if (this.#isCurrent(worker, generation)) this.#listeners.onMessage(message);
|
|
97
|
+
});
|
|
98
|
+
worker.onError((error) => {
|
|
99
|
+
if (this.#isCurrent(worker, generation)) this.#listeners.onCrash(error);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#isCurrent(worker: WorkerLike, generation: number): boolean {
|
|
104
|
+
return this.#listeners.isOpen() && this.#worker === worker && this.#generation === generation;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
4
|
+
import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
|
|
5
|
+
import { type JavaScriptKernelOptions, localBridgeConnection } from "./local-module-loader.ts";
|
|
6
|
+
import { spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
|
|
7
|
+
|
|
8
|
+
export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
9
|
+
readonly localPath?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions = {}): URL {
|
|
13
|
+
const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "worker-entry.js");
|
|
14
|
+
return pathToFileURL(resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WorkerStartupHooks {
|
|
18
|
+
readonly options: JavaScriptKernelOptions;
|
|
19
|
+
/** Wires the worker into the kernel; throws `WorkerStartupCancelledError` once the generation is stale. */
|
|
20
|
+
publish(worker: WorkerLike): void;
|
|
21
|
+
isCurrent(worker: WorkerLike): boolean;
|
|
22
|
+
retire(worker: WorkerLike): void;
|
|
23
|
+
canFallBackInline(): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, signal: AbortSignal): Promise<void> {
|
|
27
|
+
let worker = spawnWorker(hooks.options);
|
|
28
|
+
hooks.publish(worker);
|
|
29
|
+
try {
|
|
30
|
+
await initializeWorker(worker, hooks.options, signal);
|
|
31
|
+
return;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!hooks.isCurrent(worker) || error instanceof WorkerStartupCancelledError) {
|
|
34
|
+
await worker.terminate();
|
|
35
|
+
throw new WorkerStartupCancelledError();
|
|
36
|
+
}
|
|
37
|
+
if (worker.mode === "inline") throw error;
|
|
38
|
+
hooks.retire(worker);
|
|
39
|
+
await worker.terminate();
|
|
40
|
+
}
|
|
41
|
+
if (!hooks.canFallBackInline()) throw new WorkerStartupCancelledError();
|
|
42
|
+
worker = createInlineWorker(hooks.options.cwd, hooks.options.parallelPoolWidth);
|
|
43
|
+
hooks.publish(worker);
|
|
44
|
+
await initializeWorker(worker, hooks.options, signal);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
|
|
48
|
+
try {
|
|
49
|
+
const url = options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
|
|
50
|
+
return spawnNodeWorker(url, options.cwd, options.parallelPoolWidth);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (!(error instanceof Error)) throw error;
|
|
53
|
+
return createInlineWorker(options.cwd, options.parallelPoolWidth);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function initializeWorker(
|
|
58
|
+
worker: WorkerLike,
|
|
59
|
+
options: JavaScriptKernelOptions,
|
|
60
|
+
signal: AbortSignal,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const ready = waitForReady(worker, signal);
|
|
63
|
+
worker.postMessage({
|
|
64
|
+
type: "init",
|
|
65
|
+
sessionId: options.sessionId,
|
|
66
|
+
connection: localBridgeConnection(options),
|
|
67
|
+
});
|
|
68
|
+
await ready;
|
|
69
|
+
}
|
|
@@ -102,7 +102,7 @@ Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to it
|
|
|
102
102
|
A cell that outlives the foreground window detaches: it keeps its language kernel busy (another language can continue) and completes as one notification with its value or error and buffered output. Do not re-run a detached cell; read or cancel it with \`eval({ action: "peek", cell_id })\` / \`eval({ action: "stop", cell_id })\`.
|
|
103
103
|
|
|
104
104
|
{{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
|
|
105
|
-
{{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
|
|
105
|
+
{{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker, so a stop or timeout then loses every variable.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
|
|
106
106
|
{{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
|
|
107
107
|
{{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
|
|
108
108
|
On error, fix and re-run only the failing step; a normal error keeps state, while a timeout or stop message says whether the kernel restarted.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
|
|
2
|
-
import type { EvalKernel } from "./types.ts";
|
|
2
|
+
import type { EvalKernel, KernelInterruptHandle } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const INTERRUPT_DELIVERY_GRACE_MS = 100;
|
|
5
5
|
|
|
@@ -104,7 +104,8 @@ export class CellExecution {
|
|
|
104
104
|
this.#abort(this.#callerSignal.reason);
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/** Resolves with the kernel's interrupt handle once the abort reached it; undefined when no kernel was bound. */
|
|
108
|
+
interruptHandle: Promise<KernelInterruptHandle> | undefined;
|
|
108
109
|
|
|
109
110
|
#abort(reason: unknown): void {
|
|
110
111
|
if (!this.#active) return;
|
|
@@ -118,15 +119,12 @@ export class CellExecution {
|
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
120
121
|
this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
() => this.#settleAbort(error),
|
|
128
|
-
(interruptError: unknown) => this.#settleAbort(interruptError),
|
|
129
|
-
);
|
|
122
|
+
const handle = Promise.resolve().then(async () => await kernel.interrupt(error.message));
|
|
123
|
+
this.interruptHandle = handle;
|
|
124
|
+
void handle.then(
|
|
125
|
+
() => this.#settleAbort(error),
|
|
126
|
+
(interruptError: unknown) => this.#settleAbort(interruptError),
|
|
127
|
+
);
|
|
130
128
|
}
|
|
131
129
|
|
|
132
130
|
#settleAbort(reason: unknown): void {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { WakeSourceState } from "../extension/wake-source-state.ts";
|
|
3
|
+
import type { EvalLanguage, EvalToolDetails } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
|
|
6
|
+
|
|
7
|
+
export interface EvalDetachedCellSnapshot {
|
|
8
|
+
readonly cellId: string;
|
|
9
|
+
readonly language: EvalLanguage;
|
|
10
|
+
readonly state: EvalDetachedCellState;
|
|
11
|
+
readonly outputTail: string;
|
|
12
|
+
readonly result: AgentToolResult<EvalToolDetails>;
|
|
13
|
+
readonly stateRetained: boolean | undefined;
|
|
14
|
+
/** Kernel-supplied detail about the interrupt outcome, e.g. an abandoned blocked worker. */
|
|
15
|
+
readonly interruptNote?: string;
|
|
16
|
+
/** Set only when the wall-clock kill deadline ended this cell. */
|
|
17
|
+
readonly hardLimitSeconds?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EvalDetachedCellNotification {
|
|
21
|
+
readonly cellId: string;
|
|
22
|
+
readonly content: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EvalDetachedCellNotifier {
|
|
26
|
+
notify(cells: readonly EvalDetachedCellNotification[]): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface EvalDetachedCellStatusEntry {
|
|
30
|
+
readonly cellId: string;
|
|
31
|
+
readonly language: EvalLanguage;
|
|
32
|
+
readonly summary?: string;
|
|
33
|
+
readonly startedAtMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EvalDetachedCellManagerOptions {
|
|
37
|
+
readonly artifactsDir?: string;
|
|
38
|
+
readonly notifier?: EvalDetachedCellNotifier;
|
|
39
|
+
/** Wall-clock kill deadline in seconds; defaults to the bash-parity 1800s. */
|
|
40
|
+
readonly hardLimitSeconds?: number;
|
|
41
|
+
readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
|
|
42
|
+
/** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
|
|
43
|
+
readonly onWakeSourceState?: (state: WakeSourceState) => void;
|
|
44
|
+
readonly now?: () => number;
|
|
45
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
2
|
import { DEFAULT_HARD_LIMIT_SECONDS } from "../config/settings.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { WakeSourceState } from "../extension/wake-source-state.ts";
|
|
4
|
+
import type {
|
|
5
|
+
EvalDetachedCellManagerOptions,
|
|
6
|
+
EvalDetachedCellSnapshot,
|
|
7
|
+
EvalDetachedCellState,
|
|
8
|
+
EvalDetachedCellStatusEntry,
|
|
9
|
+
} from "./detached-cell-contract.ts";
|
|
4
10
|
import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
|
|
5
11
|
import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
|
|
6
12
|
import {
|
|
@@ -8,10 +14,18 @@ import {
|
|
|
8
14
|
allowsDetachedCellTransition,
|
|
9
15
|
detachedCellIsActive,
|
|
10
16
|
} from "./detached-cell-state.ts";
|
|
17
|
+
import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
|
|
11
18
|
import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
|
|
12
19
|
import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
13
20
|
|
|
14
|
-
export type
|
|
21
|
+
export type {
|
|
22
|
+
EvalDetachedCellManagerOptions,
|
|
23
|
+
EvalDetachedCellNotification,
|
|
24
|
+
EvalDetachedCellNotifier,
|
|
25
|
+
EvalDetachedCellSnapshot,
|
|
26
|
+
EvalDetachedCellState,
|
|
27
|
+
EvalDetachedCellStatusEntry,
|
|
28
|
+
} from "./detached-cell-contract.ts";
|
|
15
29
|
|
|
16
30
|
type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
|
|
17
31
|
|
|
@@ -26,6 +40,9 @@ type ManagedCell = {
|
|
|
26
40
|
wasDetached: boolean;
|
|
27
41
|
kernel: EvalKernel | undefined;
|
|
28
42
|
stateRetained: boolean | undefined;
|
|
43
|
+
interruptNote: string | undefined;
|
|
44
|
+
/** Holds the completion notification until the interrupt has reported whether kernel state survived. */
|
|
45
|
+
interruptOutcome: PromiseWithResolvers<void> | undefined;
|
|
29
46
|
liveResult: LiveResultProvider | undefined;
|
|
30
47
|
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
31
48
|
notificationQueued: boolean;
|
|
@@ -35,44 +52,6 @@ type ManagedCell = {
|
|
|
35
52
|
onHardLimit: ((error: Error) => void) | undefined;
|
|
36
53
|
};
|
|
37
54
|
|
|
38
|
-
export interface EvalDetachedCellSnapshot {
|
|
39
|
-
readonly cellId: string;
|
|
40
|
-
readonly language: EvalLanguage;
|
|
41
|
-
readonly state: EvalDetachedCellState;
|
|
42
|
-
readonly outputTail: string;
|
|
43
|
-
readonly result: AgentToolResult<EvalToolDetails>;
|
|
44
|
-
readonly stateRetained: boolean | undefined;
|
|
45
|
-
/** Set only when the wall-clock kill deadline ended this cell. */
|
|
46
|
-
readonly hardLimitSeconds?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface EvalDetachedCellNotification {
|
|
50
|
-
readonly cellId: string;
|
|
51
|
-
readonly content: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface EvalDetachedCellNotifier {
|
|
55
|
-
notify(cells: readonly EvalDetachedCellNotification[]): void;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface EvalDetachedCellStatusEntry {
|
|
59
|
-
readonly cellId: string;
|
|
60
|
-
readonly language: EvalLanguage;
|
|
61
|
-
readonly summary?: string;
|
|
62
|
-
readonly startedAtMs: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface EvalDetachedCellManagerOptions {
|
|
66
|
-
readonly artifactsDir?: string;
|
|
67
|
-
readonly notifier?: EvalDetachedCellNotifier;
|
|
68
|
-
/** Wall-clock kill deadline in seconds; defaults to the bash-parity 1800s. */
|
|
69
|
-
readonly hardLimitSeconds?: number;
|
|
70
|
-
readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
|
|
71
|
-
/** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
|
|
72
|
-
readonly onWakeSourceState?: (state: WakeSourceState) => void;
|
|
73
|
-
readonly now?: () => number;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
55
|
export function hardLimitError(cellId: string, hardLimitSeconds: number): Error {
|
|
77
56
|
const error = new Error(`Eval cell ${cellId} was killed at the ${hardLimitSeconds}s hard limit.`);
|
|
78
57
|
error.name = "TimeoutError";
|
|
@@ -114,6 +93,8 @@ export class EvalDetachedCellManager {
|
|
|
114
93
|
wasDetached: false,
|
|
115
94
|
kernel: undefined,
|
|
116
95
|
stateRetained: undefined,
|
|
96
|
+
interruptNote: undefined,
|
|
97
|
+
interruptOutcome: undefined,
|
|
117
98
|
liveResult: undefined,
|
|
118
99
|
terminalResult: undefined,
|
|
119
100
|
notificationQueued: false,
|
|
@@ -162,13 +143,7 @@ export class EvalDetachedCellManager {
|
|
|
162
143
|
|
|
163
144
|
async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
|
|
164
145
|
const cell = this.#get(cellId);
|
|
165
|
-
if (cell.state === "detached")
|
|
166
|
-
const claimed = this.#settle(cell, "cancelled", currentDetachedResult(cell));
|
|
167
|
-
if (claimed && cell.kernel !== undefined) {
|
|
168
|
-
const handle = await cell.kernel.interrupt(reason);
|
|
169
|
-
cell.stateRetained = await handle.stateRetained;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
146
|
+
if (cell.state === "detached") await this.#cancel(cell, reason);
|
|
172
147
|
return this.#snapshot(cell);
|
|
173
148
|
}
|
|
174
149
|
|
|
@@ -221,7 +196,10 @@ export class EvalDetachedCellManager {
|
|
|
221
196
|
if (!cell.notificationQueued) {
|
|
222
197
|
cell.notificationQueued = true;
|
|
223
198
|
this.#notificationQueue.enqueue({
|
|
224
|
-
snapshot: () =>
|
|
199
|
+
snapshot: async () => {
|
|
200
|
+
await cell.interruptOutcome?.promise;
|
|
201
|
+
return this.#snapshot(cell);
|
|
202
|
+
},
|
|
225
203
|
spillPath: cell.spillPath,
|
|
226
204
|
});
|
|
227
205
|
}
|
|
@@ -250,40 +228,34 @@ export class EvalDetachedCellManager {
|
|
|
250
228
|
const foreground = cell.state === "running" && cell.onHardLimit !== undefined;
|
|
251
229
|
cell.hardLimited = true;
|
|
252
230
|
const error = hardLimitError(cell.cellId, cell.hardLimitSeconds);
|
|
253
|
-
if (!this.#settle(cell, "cancelled", currentDetachedResult(cell))) return;
|
|
254
231
|
if (foreground) {
|
|
255
|
-
cell.onHardLimit?.(error);
|
|
232
|
+
if (this.#settle(cell, "cancelled", currentDetachedResult(cell))) cell.onHardLimit?.(error);
|
|
256
233
|
return;
|
|
257
234
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
235
|
+
await this.#cancel(cell, error.message);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #cancel(cell: ManagedCell, reason: string): Promise<void> {
|
|
239
|
+
const outcome = Promise.withResolvers<void>();
|
|
240
|
+
cell.interruptOutcome = outcome;
|
|
241
|
+
try {
|
|
242
|
+
if (!this.#settle(cell, "cancelled", currentDetachedResult(cell)) || cell.kernel === undefined) return;
|
|
243
|
+
const handle = await cell.kernel.interrupt(reason);
|
|
244
|
+
cell.interruptNote = handle.note;
|
|
245
|
+
cell.stateRetained = await handle.stateRetained;
|
|
246
|
+
} finally {
|
|
247
|
+
outcome.resolve();
|
|
248
|
+
}
|
|
261
249
|
}
|
|
262
250
|
|
|
263
251
|
#emitStatus(): void {
|
|
264
252
|
const liveCells = [...this.#detachedByLanguage.values()];
|
|
265
|
-
this.#onStatusChange?.(
|
|
266
|
-
liveCells.map((cell) => ({
|
|
267
|
-
cellId: cell.cellId,
|
|
268
|
-
language: cell.input.language,
|
|
269
|
-
startedAtMs: cell.startedAtMs,
|
|
270
|
-
...(cell.input.summary === undefined ? {} : { summary: cell.input.summary }),
|
|
271
|
-
})),
|
|
272
|
-
);
|
|
253
|
+
this.#onStatusChange?.(detachedStatusEntries(liveCells));
|
|
273
254
|
this.#emitWakeSourceState(liveCells);
|
|
274
255
|
}
|
|
275
256
|
|
|
276
257
|
#emitWakeSourceState(liveCells: readonly ManagedCell[]): void {
|
|
277
|
-
this.#onWakeSourceState?.(
|
|
278
|
-
source: SENPI_CODEMODE_WAKE_SOURCE,
|
|
279
|
-
activeCount: liveCells.length,
|
|
280
|
-
items: liveCells.map((cell) => ({
|
|
281
|
-
id: cell.cellId,
|
|
282
|
-
description:
|
|
283
|
-
cell.input.summary === undefined || cell.input.summary.length === 0 ? cell.cellId : cell.input.summary,
|
|
284
|
-
startedAtMs: cell.startedAtMs,
|
|
285
|
-
})),
|
|
286
|
-
});
|
|
258
|
+
this.#onWakeSourceState?.(detachedWakeSourceState(liveCells));
|
|
287
259
|
}
|
|
288
260
|
|
|
289
261
|
#snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
|
|
4
5
|
|
|
5
6
|
const NOTIFICATION_TAIL_BYTES = 512;
|
|
6
7
|
|
|
@@ -74,11 +75,9 @@ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
|
|
77
|
-
if (cell.state
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return "Python kernel was interrupted; its existing variables are preserved.";
|
|
81
|
-
return "Kernel state updated - variables are available to the next eval cell.";
|
|
78
|
+
if (cell.state !== "cancelled") return "Kernel state updated - variables are available to the next eval cell.";
|
|
79
|
+
const note = interruptionStateNote(cell.language, cell.stateRetained) ?? unknownInterruptionStateNote(cell.language);
|
|
80
|
+
return cell.interruptNote === undefined ? note : `${note} ${cell.interruptNote.trim()}`;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
function safeCellId(cellId: string): string {
|
|
@@ -10,6 +10,7 @@ export interface DetachedCellResultSource {
|
|
|
10
10
|
state: EvalDetachedCellState;
|
|
11
11
|
kernel: EvalKernel | undefined;
|
|
12
12
|
stateRetained: boolean | undefined;
|
|
13
|
+
interruptNote?: string | undefined;
|
|
13
14
|
liveResult: (() => AgentToolResult<EvalToolDetails>) | undefined;
|
|
14
15
|
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
15
16
|
hardLimited?: boolean;
|
|
@@ -26,6 +27,7 @@ export function snapshotDetachedCell(cell: DetachedCellResultSource, nowMs: numb
|
|
|
26
27
|
outputTail: detachedOutputTail(result),
|
|
27
28
|
result,
|
|
28
29
|
stateRetained: cell.stateRetained,
|
|
30
|
+
...(cell.interruptNote === undefined ? {} : { interruptNote: cell.interruptNote }),
|
|
29
31
|
...(cell.hardLimited === true && cell.hardLimitSeconds !== undefined
|
|
30
32
|
? { hardLimitSeconds: cell.hardLimitSeconds }
|
|
31
33
|
: {}),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SENPI_CODEMODE_WAKE_SOURCE, type WakeSourceState } from "../extension/wake-source-state.ts";
|
|
2
|
+
import type { EvalDetachedCellStatusEntry } from "./detached-cell-manager.ts";
|
|
3
|
+
|
|
4
|
+
export interface LiveDetachedCell {
|
|
5
|
+
readonly cellId: string;
|
|
6
|
+
readonly startedAtMs: number;
|
|
7
|
+
readonly input: { readonly language: EvalDetachedCellStatusEntry["language"]; readonly summary?: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function detachedStatusEntries(liveCells: readonly LiveDetachedCell[]): EvalDetachedCellStatusEntry[] {
|
|
11
|
+
return liveCells.map((cell) => ({
|
|
12
|
+
cellId: cell.cellId,
|
|
13
|
+
language: cell.input.language,
|
|
14
|
+
startedAtMs: cell.startedAtMs,
|
|
15
|
+
...(cell.input.summary === undefined ? {} : { summary: cell.input.summary }),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function detachedWakeSourceState(liveCells: readonly LiveDetachedCell[]): WakeSourceState {
|
|
20
|
+
return {
|
|
21
|
+
source: SENPI_CODEMODE_WAKE_SOURCE,
|
|
22
|
+
activeCount: liveCells.length,
|
|
23
|
+
items: liveCells.map((cell) => ({
|
|
24
|
+
id: cell.cellId,
|
|
25
|
+
description:
|
|
26
|
+
cell.input.summary === undefined || cell.input.summary.length === 0 ? cell.cellId : cell.input.summary,
|
|
27
|
+
startedAtMs: cell.startedAtMs,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -40,6 +40,7 @@ export function createDetachedControlResult(snapshot: EvalDetachedCellSnapshot):
|
|
|
40
40
|
`Eval cell ${snapshot.cellId} (${snapshot.language}) is ${snapshot.state}.`,
|
|
41
41
|
output.length === 0 ? "(no buffered output)" : output,
|
|
42
42
|
...(terminationNote === undefined ? [] : [terminationNote]),
|
|
43
|
+
...(snapshot.interruptNote === undefined ? [] : [snapshot.interruptNote.trim()]),
|
|
43
44
|
].join("\n");
|
|
44
45
|
return {
|
|
45
46
|
content: [{ type: "text", text }, ...snapshot.result.content.filter((part) => part.type === "image")],
|
|
@@ -2,7 +2,7 @@ import type { EvalDetachedCellNotifier, EvalDetachedCellSnapshot } from "./detac
|
|
|
2
2
|
import { buildDetachedCellNotification } from "./detached-cell-notification.ts";
|
|
3
3
|
|
|
4
4
|
export interface PendingDetachedNotification {
|
|
5
|
-
readonly snapshot: () => EvalDetachedCellSnapshot
|
|
5
|
+
readonly snapshot: () => EvalDetachedCellSnapshot | Promise<EvalDetachedCellSnapshot>;
|
|
6
6
|
readonly spillPath: string | undefined;
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -30,7 +30,7 @@ export class DetachedNotificationQueue {
|
|
|
30
30
|
const flush = Promise.resolve().then(async () => {
|
|
31
31
|
const pending = this.#pending.splice(0);
|
|
32
32
|
const notifications = await Promise.all(
|
|
33
|
-
pending.map(async (item) => await buildDetachedCellNotification(item.snapshot(), item.spillPath)),
|
|
33
|
+
pending.map(async (item) => await buildDetachedCellNotification(await item.snapshot(), item.spillPath)),
|
|
34
34
|
);
|
|
35
35
|
this.#notifier?.notify(notifications);
|
|
36
36
|
});
|