@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.8
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 +37 -0
- package/README.md +20 -2
- package/package.json +5 -4
- package/src/bridge/kernel-tools-protocol.ts +8 -0
- package/src/bridge/reserved.ts +10 -0
- package/src/bridges/eval-virtual-schemas.ts +100 -0
- package/src/bridges/handle-bridge.ts +72 -0
- package/src/bridges/reserved-dispatch.ts +20 -2
- package/src/bridges/schema-bridge.ts +4 -0
- package/src/completion/handle.ts +69 -0
- package/src/completion/tool-bridge.ts +31 -12
- package/src/environments/editable-check.ts +116 -0
- package/src/environments/install-lock.ts +223 -0
- package/src/environments/py-environment.ts +98 -0
- package/src/environments/py-installer.ts +187 -0
- package/src/environments/revision-store.ts +95 -0
- package/src/extension/bridge-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/host-facts.ts +18 -0
- package/src/extension/kernel-registration.ts +5 -0
- package/src/extension/removed-tool-hints.ts +15 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +32 -5
- package/src/extension/start-recovery.ts +111 -0
- package/src/handles/completion-handles.ts +202 -0
- package/src/handles/handle-args.ts +103 -0
- package/src/handles/handle-registry.ts +164 -0
- package/src/handles/wait.ts +214 -0
- package/src/handles/watch-queue.ts +102 -0
- package/src/index.ts +99 -94
- package/src/kernels/jl/handles.jl +124 -0
- package/src/kernels/jl/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +6 -1
- package/src/kernels/js/handles.js +93 -0
- package/src/kernels/js/kernel-tools-define.d.ts +6 -0
- package/src/kernels/js/kernel-tools-define.js +3 -1
- package/src/kernels/js/kernel-tools-errors.js +2 -0
- package/src/kernels/js/kernel-tools-errors.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +18 -9
- package/src/kernels/js/kernel-tools-schema.js +6 -2
- package/src/kernels/js/worker-runtime.js +10 -1
- package/src/kernels/py/host-cell.ts +42 -0
- package/src/kernels/py/kernel-contract.ts +13 -2
- package/src/kernels/py/kernel-tools-host.ts +97 -0
- package/src/kernels/py/kernel.ts +88 -12
- package/src/kernels/py/prelude.py +797 -11
- package/src/kernels/py/process.ts +15 -4
- package/src/kernels/py/startup.ts +58 -20
- package/src/kernels/py/transport.ts +36 -7
- package/src/kernels/rb/handles.rb +97 -0
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +7 -7
- package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
- package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
- package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
- package/src/kernels/shared/kernel-tools-pump.ts +6 -0
- package/src/kernels/shared/process-cpu.ts +69 -0
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/cell-handler.ts +21 -18
- package/src/tool/cell-reserved-dispatch.ts +51 -0
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/run-eval-cell.ts +2 -0
- package/src/tool/types.ts +15 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
|
|
5
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
6
|
+
|
|
7
|
+
const POINTER = "active";
|
|
8
|
+
const REVISION = /^rev-(\d+)$/;
|
|
9
|
+
|
|
10
|
+
export interface Revision {
|
|
11
|
+
readonly number: number;
|
|
12
|
+
readonly dir: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readActiveRevision(base: string): Promise<Revision | undefined> {
|
|
16
|
+
try {
|
|
17
|
+
const name = (await readFile(join(base, POINTER), "utf8")).trim();
|
|
18
|
+
const match = REVISION.exec(name);
|
|
19
|
+
return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the next revision under the root's lock: a staging copy of the active revision (so packages
|
|
28
|
+
* accumulate) is handed to `build`, renamed to `rev-<n>` only after `build` succeeds, and published by an
|
|
29
|
+
* atomic replace of the `active` pointer. A failed, cancelled or interrupted build is deleted; the
|
|
30
|
+
* previous revision stays active and is never modified, so a running kernel never sees a partial install.
|
|
31
|
+
*/
|
|
32
|
+
export async function publishNextRevision(
|
|
33
|
+
base: string,
|
|
34
|
+
build: (staging: string, previous: Revision | undefined) => Promise<void>,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
onLockWait?: (notice: LockWaitNotice) => void,
|
|
37
|
+
): Promise<{ readonly revision: Revision; readonly previous: Revision | undefined }> {
|
|
38
|
+
await mkdir(base, { recursive: true });
|
|
39
|
+
return withRootLock(
|
|
40
|
+
base,
|
|
41
|
+
async () => {
|
|
42
|
+
const previous = await readActiveRevision(base);
|
|
43
|
+
const number = Math.max(previous?.number ?? 0, await highestRevision(base)) + 1;
|
|
44
|
+
const staging = join(base, `.staging-rev-${number}-${process.pid}-${randomUUID()}`);
|
|
45
|
+
const dir = join(base, `rev-${number}`);
|
|
46
|
+
try {
|
|
47
|
+
if (previous === undefined) await mkdir(staging, { recursive: true });
|
|
48
|
+
else await cp(previous.dir, staging, { recursive: true, verbatimSymlinks: true });
|
|
49
|
+
await build(staging, previous);
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
await renameRetrying(staging, dir);
|
|
52
|
+
const pointer = join(base, `.${POINTER}-${process.pid}-${randomUUID()}`);
|
|
53
|
+
await writeFile(pointer, `rev-${number}\n`);
|
|
54
|
+
await renameRetrying(pointer, join(base, POINTER));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await rm(staging, { recursive: true, force: true });
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return { revision: { number, dir }, previous };
|
|
60
|
+
},
|
|
61
|
+
signal,
|
|
62
|
+
onLockWait,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RENAME_RETRY_DELAYS_MS = [20, 50, 100, 200, 400, 800];
|
|
67
|
+
|
|
68
|
+
// On Windows a rename fails with EPERM/EBUSY/EACCES while another process (an indexer, antivirus) holds a
|
|
69
|
+
// handle on the source or the replaced file; those holds are brief, so the rename is retried before failing.
|
|
70
|
+
async function renameRetrying(from: string, to: string): Promise<void> {
|
|
71
|
+
for (let attempt = 0; ; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
await rename(from, to);
|
|
74
|
+
return;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
77
|
+
const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
78
|
+
const delay = RENAME_RETRY_DELAYS_MS[attempt];
|
|
79
|
+
if (!transient || delay === undefined) {
|
|
80
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
81
|
+
throw new EnvironmentError("environment_install_failed", `could not publish the new revision: ${reason}`);
|
|
82
|
+
}
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function highestRevision(base: string): Promise<number> {
|
|
89
|
+
let highest = 0;
|
|
90
|
+
for (const entry of await readdir(base)) {
|
|
91
|
+
const match = REVISION.exec(entry);
|
|
92
|
+
if (match?.[1] !== undefined) highest = Math.max(highest, Number(match[1]));
|
|
93
|
+
}
|
|
94
|
+
return highest;
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { BridgeHttpCompletionRequest } from "../bridge/http-server.ts";
|
|
3
|
+
import { startCompletionHandle, wantsCompletionHandle } from "../completion/handle.ts";
|
|
4
|
+
import type { CompletionRequest } from "../completion/handler.ts";
|
|
5
|
+
import { resolveHardLimitSeconds } from "../config/settings.ts";
|
|
6
|
+
import type { CreateCodemodeSessionManagerOptions } from "./session-manager-contract.ts";
|
|
7
|
+
|
|
8
|
+
export interface BridgeDispatchContext {
|
|
9
|
+
/** The context of the cell that last reached the manager; the handle host and completion owner are read from it. */
|
|
10
|
+
readonly context: ExtensionContext | undefined;
|
|
11
|
+
readonly contextFor: (signal: AbortSignal) => ExtensionContext;
|
|
12
|
+
readonly requireContext: () => ExtensionContext;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `completion(prompt, handle=True)` from a subprocess kernel: the handle lives in the session registry and is
|
|
17
|
+
* bounded by the hard limit measured from this request, the tightest bound the bridge knows for its cell.
|
|
18
|
+
*/
|
|
19
|
+
export async function dispatchBridgeCompletion(
|
|
20
|
+
options: CreateCodemodeSessionManagerOptions,
|
|
21
|
+
request: BridgeHttpCompletionRequest,
|
|
22
|
+
dispatch: BridgeDispatchContext,
|
|
23
|
+
): Promise<unknown> {
|
|
24
|
+
const completion: CompletionRequest = { prompt: request.prompt, opts: request.opts };
|
|
25
|
+
if (!wantsCompletionHandle(request.opts)) {
|
|
26
|
+
return await options.complete(completion, dispatch.contextFor(request.signal));
|
|
27
|
+
}
|
|
28
|
+
const registry = options.handles;
|
|
29
|
+
if (registry === undefined) {
|
|
30
|
+
throw Object.assign(new Error("eval_wait_unavailable: completion handles are not available in this session"), {
|
|
31
|
+
code: "eval_wait_unavailable",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return startCompletionHandle({
|
|
35
|
+
registry,
|
|
36
|
+
request: completion,
|
|
37
|
+
complete: options.complete,
|
|
38
|
+
ctx: dispatch.requireContext(),
|
|
39
|
+
deadlineMs: Date.now() + resolveHardLimitSeconds(options.settings) * 1_000,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EvalHandleHost } from "@code-yeongyu/senpi";
|
|
1
2
|
import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
|
|
2
3
|
import { defaultCodemodeSettings } from "../config/settings.ts";
|
|
3
4
|
import { marshalToolResult } from "../tool/image.ts";
|
|
@@ -16,8 +17,9 @@ export interface BridgeToolCallRequest {
|
|
|
16
17
|
// and ordinary tool results are marshalled to { text, images, details, hasError } — the raw
|
|
17
18
|
// { content } shape left python cells unable to reach tool.read image blocks.
|
|
18
19
|
export async function routeBridgeToolCall(
|
|
19
|
-
options: Pick<CreateCodemodeSessionManagerOptions, "executeTool" | "listTools" | "settings">,
|
|
20
|
+
options: Pick<CreateCodemodeSessionManagerOptions, "executeTool" | "listTools" | "settings" | "handles">,
|
|
20
21
|
request: BridgeToolCallRequest,
|
|
22
|
+
evalHandleHost?: EvalHandleHost,
|
|
21
23
|
): Promise<unknown> {
|
|
22
24
|
if (!isReservedToolName(request.toolName)) {
|
|
23
25
|
return marshalToolResult(await options.executeTool(request.toolName, request.args, { signal: request.signal }));
|
|
@@ -33,5 +35,7 @@ export async function routeBridgeToolCall(
|
|
|
33
35
|
signal: request.signal,
|
|
34
36
|
emitStatus: () => {},
|
|
35
37
|
marshalToolResult,
|
|
38
|
+
...(options.handles === undefined ? {} : { handles: options.handles }),
|
|
39
|
+
...(evalHandleHost === undefined ? {} : { evalHandleHost }),
|
|
36
40
|
});
|
|
37
41
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import { jsRuntimeLabel } from "./runtime-info.ts";
|
|
3
|
+
|
|
4
|
+
/** The one-line host description the eval prompt shows (platform, CPU, cores, JS runtime). */
|
|
5
|
+
export function hostLine(): string {
|
|
6
|
+
const cpu = os.cpus()[0]?.model?.trim();
|
|
7
|
+
return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`, jsRuntimeLabel()]
|
|
8
|
+
.filter((part): part is string => !!part)
|
|
9
|
+
.join(" \u00b7 ");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The model id carried by a `model_select` event, when it has one. */
|
|
13
|
+
export function modelIdFrom(event: unknown): string | undefined {
|
|
14
|
+
if (typeof event !== "object" || event === null || !("model" in event)) return undefined;
|
|
15
|
+
const model = event.model;
|
|
16
|
+
if (typeof model !== "object" || model === null || !("id" in model)) return undefined;
|
|
17
|
+
return typeof model.id === "string" ? model.id : undefined;
|
|
18
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../bridge/prot
|
|
|
4
4
|
import { JuliaKernel } from "../kernels/jl/kernel.ts";
|
|
5
5
|
import type { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
6
6
|
import { PythonKernel } from "../kernels/py/kernel.ts";
|
|
7
|
+
import type { PeerKernelToolsDescribe } from "../kernels/py/kernel-tools-host.ts";
|
|
7
8
|
import { defaultSpawn } from "../kernels/py/process.ts";
|
|
8
9
|
import { RubyKernel } from "../kernels/rb/kernel.ts";
|
|
9
10
|
import type { SessionEnvironment } from "../kernels/session-env.ts";
|
|
@@ -27,6 +28,7 @@ export interface SubprocessKernelStart {
|
|
|
27
28
|
readonly connection: BridgeConnectionConfig;
|
|
28
29
|
readonly onMessage: (message: KernelToHostMessage) => void;
|
|
29
30
|
};
|
|
31
|
+
readonly peerKernelToolsDescribe?: PeerKernelToolsDescribe;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function registerKernel(sessionId: string, language: EvalLanguage, memory: RegisteredKernelSource): string {
|
|
@@ -82,6 +84,9 @@ export async function startSubprocessKernel(start: SubprocessKernelStart): Promi
|
|
|
82
84
|
...shared,
|
|
83
85
|
interpreterPath: start.interpreterPath,
|
|
84
86
|
memory,
|
|
87
|
+
...(start.peerKernelToolsDescribe === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { peerKernelToolsDescribe: start.peerKernelToolsDescribe }),
|
|
85
90
|
spawnProcess: (options) => {
|
|
86
91
|
const child = defaultSpawn(options);
|
|
87
92
|
pid = child.pid;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Migration guidance for the tools eval replaced; the host returns it when a model calls them. */
|
|
2
|
+
export interface RemovedToolHintRegistrar {
|
|
3
|
+
registerRemovedToolHint(name: string, hint: string): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const WAIT_REMOVED_TOOL_HINT =
|
|
7
|
+
"wait is not a tool; inside a cell use the wait(handles) helper (tool_schema('eval:wait')), or let detached cells notify.";
|
|
8
|
+
|
|
9
|
+
export function registerRemovedToolHints(pi: RemovedToolHintRegistrar): void {
|
|
10
|
+
pi.registerRemovedToolHint(
|
|
11
|
+
"exec",
|
|
12
|
+
'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on their own and notify when complete.',
|
|
13
|
+
);
|
|
14
|
+
pi.registerRemovedToolHint("wait", WAIT_REMOVED_TOOL_HINT);
|
|
15
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ResolvedCodemodeSettings,
|
|
9
9
|
resolveEnabledLanguages,
|
|
10
10
|
} from "../config/settings.ts";
|
|
11
|
+
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
11
12
|
import {
|
|
12
13
|
createInterpreterDetector,
|
|
13
14
|
getInterpreterAvailability,
|
|
@@ -54,6 +55,7 @@ export async function createRuntime(
|
|
|
54
55
|
event: unknown,
|
|
55
56
|
complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>,
|
|
56
57
|
options: RuntimeFactoryOptions,
|
|
58
|
+
handles?: HandleRegistry,
|
|
57
59
|
): Promise<SessionRuntime> {
|
|
58
60
|
const loaded = await loadCodemodeSettings({ cwd: ctx.cwd });
|
|
59
61
|
const settings: ResolvedCodemodeSettings = {
|
|
@@ -81,6 +83,7 @@ export async function createRuntime(
|
|
|
81
83
|
executeTool,
|
|
82
84
|
listTools: () => pi.getAllTools(),
|
|
83
85
|
complete,
|
|
86
|
+
...(handles === undefined ? {} : { handles }),
|
|
84
87
|
});
|
|
85
88
|
return {
|
|
86
89
|
sessionId,
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
|
2
2
|
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
3
3
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
4
4
|
import type { CodemodeSettings } from "../config/settings.ts";
|
|
5
|
+
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
5
6
|
import type { InterpreterAvailability } from "../interpreters/detect.ts";
|
|
6
7
|
import type { SessionEnvironment } from "../kernels/session-env.ts";
|
|
7
8
|
import type { EvalKernelManager, ExecuteTool } from "../tool/types.ts";
|
|
@@ -39,4 +40,6 @@ export interface CreateCodemodeSessionManagerOptions {
|
|
|
39
40
|
readonly executeTool: ExecuteTool;
|
|
40
41
|
readonly listTools?: () => readonly EvalSchemaToolInfo[];
|
|
41
42
|
readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
|
|
43
|
+
/** The session generation's handle registry; subprocess kernels reach `wait()`/`handle()` through the bridge. */
|
|
44
|
+
readonly handles?: HandleRegistry;
|
|
42
45
|
}
|
|
@@ -6,8 +6,10 @@ import type { CompletionRequest, CompletionResult } from "../completion/handler.
|
|
|
6
6
|
import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
7
7
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
8
8
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
9
|
+
import type { KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
|
|
9
10
|
import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
|
|
10
11
|
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
12
|
+
import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
|
|
11
13
|
import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
|
|
12
14
|
import { parkWhenIdle } from "./idle-parking-kernel.ts";
|
|
13
15
|
import {
|
|
@@ -73,16 +75,23 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
async start(): Promise<void> {
|
|
78
|
+
const manager = this;
|
|
79
|
+
const dispatch: BridgeDispatchContext = {
|
|
80
|
+
get context() {
|
|
81
|
+
return manager.#context;
|
|
82
|
+
},
|
|
83
|
+
contextFor: (signal) => this.#contextFor(signal),
|
|
84
|
+
requireContext: () => this.#requireContext(),
|
|
85
|
+
};
|
|
76
86
|
this.#bridge = await startBridgeServer({
|
|
77
87
|
onCall: async (request) => await this.#call(request),
|
|
78
88
|
onEmit: async () => undefined,
|
|
79
|
-
onCompletion: async (request) =>
|
|
80
|
-
this.#options.complete({ prompt: request.prompt, opts: request.opts }, this.#contextFor(request.signal)),
|
|
89
|
+
onCompletion: async (request) => await dispatchBridgeCompletion(this.#options, request, dispatch),
|
|
81
90
|
});
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
async #call(request: BridgeToolCallRequest): Promise<unknown> {
|
|
85
|
-
return await routeBridgeToolCall(this.#options, request);
|
|
94
|
+
return await routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
|
|
@@ -268,7 +277,20 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
268
277
|
...lifecycle,
|
|
269
278
|
};
|
|
270
279
|
const memory = resolveKernelMemoryThresholds(this.#options.settings.memory);
|
|
271
|
-
return await startSubprocessKernel({
|
|
280
|
+
return await startSubprocessKernel({
|
|
281
|
+
language,
|
|
282
|
+
interpreterPath: detected.path,
|
|
283
|
+
memory,
|
|
284
|
+
shared,
|
|
285
|
+
peerKernelToolsDescribe: (names) => this.#javaScriptKernelToolsDescribe(names),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Only an already running JavaScript kernel can define a colliding name; none is started for this. */
|
|
290
|
+
#javaScriptKernelToolsDescribe(names: readonly string[]): Promise<KernelToolsDescribeResult> | undefined {
|
|
291
|
+
const js = this.#kernels.get("js");
|
|
292
|
+
if (!js || !("describeKernelTools" in js) || typeof js.describeKernelTools !== "function") return undefined;
|
|
293
|
+
return (js.describeKernelTools as (names: readonly string[]) => Promise<KernelToolsDescribeResult>)(names);
|
|
272
294
|
}
|
|
273
295
|
|
|
274
296
|
#foreignKernelToolNames(): string[] {
|
|
@@ -280,9 +302,14 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
280
302
|
return names;
|
|
281
303
|
}
|
|
282
304
|
|
|
283
|
-
#
|
|
305
|
+
#requireContext(): ExtensionContext {
|
|
284
306
|
const ctx = this.#context;
|
|
285
307
|
if (!ctx) throw new CodemodeContextUnavailableError();
|
|
308
|
+
return ctx;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#contextFor(signal: AbortSignal): ExtensionContext {
|
|
312
|
+
const ctx = this.#requireContext();
|
|
286
313
|
return { ...ctx, signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal };
|
|
287
314
|
}
|
|
288
315
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
3
|
+
import type { createEvalTool } from "../tool/eval-tool.ts";
|
|
4
|
+
import { CodemodeSessionDisposedError } from "./session-manager.ts";
|
|
5
|
+
import { CodemodeSessionNotStartedError } from "./session-manager-proxy.ts";
|
|
6
|
+
|
|
7
|
+
export class CodemodeRuntimeRecreationError extends Error {
|
|
8
|
+
readonly name = "CodemodeRuntimeRecreationError";
|
|
9
|
+
|
|
10
|
+
constructor(reason: string) {
|
|
11
|
+
super(`codemode runtime could not be re-created: ${reason}. Start a new session or reload to bring eval back.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type StartSession = (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A session_start whose runtime could not be created leaves the session running with no usable manager. The
|
|
19
|
+
* next eval re-creates the runtime once for that failed start (concurrent evals share the attempt) and runs; a
|
|
20
|
+
* failed re-creation is reported to every later eval until the next session_start. One stderr line names the
|
|
21
|
+
* failed start with a hashed session id so a recurrence stays visible. A session that ended is never recovered.
|
|
22
|
+
*/
|
|
23
|
+
export class StartRecovery {
|
|
24
|
+
readonly #startSession: StartSession;
|
|
25
|
+
readonly #report: (line: string) => void;
|
|
26
|
+
#failedStartEvent: { readonly event: unknown } | undefined;
|
|
27
|
+
#recreation: Promise<void> | undefined;
|
|
28
|
+
#recreationFailure: CodemodeRuntimeRecreationError | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
startSession: StartSession,
|
|
32
|
+
report: (line: string) => void = (line) => globalThis.process.stderr.write(`${line}\n`),
|
|
33
|
+
) {
|
|
34
|
+
this.#startSession = startSession;
|
|
35
|
+
this.#report = report;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async runStart(event: unknown, ctx: ExtensionContext): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await this.#startSession(event, ctx);
|
|
41
|
+
this.#reset();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
this.#reset();
|
|
44
|
+
this.#failedStartEvent = { event };
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
sessionEnded(): void {
|
|
50
|
+
this.#reset();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
wrapExecute<Args extends unknown[], Result>(
|
|
54
|
+
execute: (...args: Args) => Promise<Result>,
|
|
55
|
+
contextOf: (args: Args) => ExtensionContext,
|
|
56
|
+
): (...args: Args) => Promise<Result> {
|
|
57
|
+
return async (...args) => {
|
|
58
|
+
try {
|
|
59
|
+
return await execute(...args);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof CodemodeSessionDisposedError || error instanceof CodemodeSessionNotStartedError)) {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
if (this.#recreationFailure !== undefined) throw this.#recreationFailure;
|
|
65
|
+
const recreation = this.#recreation ?? this.#beginRecreation(contextOf(args));
|
|
66
|
+
if (recreation === undefined) throw error;
|
|
67
|
+
await recreation;
|
|
68
|
+
return await execute(...args);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#beginRecreation(ctx: ExtensionContext): Promise<void> | undefined {
|
|
74
|
+
const failed = this.#failedStartEvent;
|
|
75
|
+
if (failed === undefined) return undefined;
|
|
76
|
+
this.#report(
|
|
77
|
+
`[senpi-codemode] eval re-created a runtime left disposed by a failed session_start (session ${sessionHash(ctx)})`,
|
|
78
|
+
);
|
|
79
|
+
const recreation = this.#startSession(failed.event, ctx).then(
|
|
80
|
+
() => {
|
|
81
|
+
this.#recreation = undefined;
|
|
82
|
+
this.#reset();
|
|
83
|
+
},
|
|
84
|
+
(error: unknown) => {
|
|
85
|
+
this.#recreation = undefined;
|
|
86
|
+
this.#recreationFailure = new CodemodeRuntimeRecreationError(
|
|
87
|
+
error instanceof Error ? error.message : String(error),
|
|
88
|
+
);
|
|
89
|
+
throw this.#recreationFailure;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
this.#recreation = recreation;
|
|
93
|
+
return recreation;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#reset(): void {
|
|
97
|
+
this.#failedStartEvent = undefined;
|
|
98
|
+
this.#recreationFailure = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function withStartRecovery<Tool extends ReturnType<typeof createEvalTool>>(
|
|
103
|
+
recovery: StartRecovery,
|
|
104
|
+
tool: Tool,
|
|
105
|
+
): Tool {
|
|
106
|
+
return { ...tool, execute: recovery.wrapExecute(tool.execute, (args) => args[4]) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sessionHash(ctx: ExtensionContext): string {
|
|
110
|
+
return createHash("sha256").update(ctx.sessionManager.getSessionId()).digest("hex").slice(0, 12);
|
|
111
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
type CancelReceipt,
|
|
4
|
+
EvalHandleError,
|
|
5
|
+
type HandleError,
|
|
6
|
+
type HandleOutcome,
|
|
7
|
+
type HandlePhase,
|
|
8
|
+
type HandleRef,
|
|
9
|
+
type HandleSnapshot,
|
|
10
|
+
type HandleWatch,
|
|
11
|
+
type OutputRequest,
|
|
12
|
+
type OutputSnapshot,
|
|
13
|
+
} from "@code-yeongyu/senpi";
|
|
14
|
+
import { refKey } from "./handle-args.ts";
|
|
15
|
+
import { WatchQueue } from "./watch-queue.ts";
|
|
16
|
+
|
|
17
|
+
export interface CompletionStart {
|
|
18
|
+
/** Runs the host completion; the signal aborts on cancel, on the deadline, and when the session generation drops. */
|
|
19
|
+
readonly run: (signal: AbortSignal) => Promise<unknown>;
|
|
20
|
+
/** Absolute wall-clock deadline (ms), derived from the creating cell's hard deadline. */
|
|
21
|
+
readonly deadlineMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CompletionEntry {
|
|
25
|
+
readonly ref: HandleRef;
|
|
26
|
+
phase: HandlePhase;
|
|
27
|
+
hostStatus: string;
|
|
28
|
+
revision: number;
|
|
29
|
+
value?: unknown;
|
|
30
|
+
error?: HandleError;
|
|
31
|
+
readonly controller: AbortController;
|
|
32
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
deadlineHit: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TERMINAL: ReadonlySet<HandlePhase> = new Set(["succeeded", "failed", "cancelled", "lost"]);
|
|
37
|
+
const DEADLINE_MESSAGE = "completion handle exceeded its cell's hard deadline";
|
|
38
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* codemode-owned completion handles (`completion(prompt, {handle: true})`). They live in the session's
|
|
42
|
+
* registry, so they survive a kernel restart and need no host capability; once the session generation
|
|
43
|
+
* is dropped every ref fails closed with `eval_handle_stale`.
|
|
44
|
+
*/
|
|
45
|
+
export class CompletionHandles {
|
|
46
|
+
readonly #entries = new Map<string, CompletionEntry>();
|
|
47
|
+
readonly #watches = new Set<WatchQueue>();
|
|
48
|
+
readonly #now: () => number;
|
|
49
|
+
#disposed = false;
|
|
50
|
+
|
|
51
|
+
constructor(now: () => number = Date.now) {
|
|
52
|
+
this.#now = now;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
start(input: CompletionStart): HandleRef {
|
|
56
|
+
this.#assertLive();
|
|
57
|
+
const ref: HandleRef = { kind: "completion", id: `cp_${randomBytes(16).toString("hex")}`, run_epoch: 0 };
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const entry: CompletionEntry = {
|
|
60
|
+
ref,
|
|
61
|
+
phase: "pending",
|
|
62
|
+
hostStatus: "running",
|
|
63
|
+
revision: 1,
|
|
64
|
+
controller,
|
|
65
|
+
timer: undefined,
|
|
66
|
+
deadlineHit: false,
|
|
67
|
+
};
|
|
68
|
+
this.#entries.set(ref.id, entry);
|
|
69
|
+
const remainingMs = Math.min(MAX_TIMER_MS, Math.max(0, input.deadlineMs - this.#now()));
|
|
70
|
+
if (Number.isFinite(remainingMs)) {
|
|
71
|
+
entry.timer = setTimeout(() => {
|
|
72
|
+
entry.deadlineHit = true;
|
|
73
|
+
controller.abort(new Error(DEADLINE_MESSAGE));
|
|
74
|
+
}, remainingMs);
|
|
75
|
+
entry.timer.unref?.();
|
|
76
|
+
}
|
|
77
|
+
input.run(controller.signal).then(
|
|
78
|
+
(value) => this.#settle(entry, { phase: "succeeded", hostStatus: "completed", value }),
|
|
79
|
+
(error: unknown) => {
|
|
80
|
+
// cancel() and dispose() settle the entry before aborting; their rejection is not an outcome.
|
|
81
|
+
if (TERMINAL.has(entry.phase)) return;
|
|
82
|
+
const hostStatus = entry.deadlineHit ? "deadline" : "failed";
|
|
83
|
+
this.#settle(entry, { phase: "failed", hostStatus, error: toHandleError(error, entry.deadlineHit) });
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
return ref;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get openWatches(): number {
|
|
90
|
+
return this.#watches.size;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
owns(ref: HandleRef): boolean {
|
|
94
|
+
return ref.kind === "completion";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
watch(refs: readonly HandleRef[]): HandleWatch {
|
|
98
|
+
const entries = refs.map((ref) => this.#fence(ref));
|
|
99
|
+
const queue = new WatchQueue(refs.map(refKey));
|
|
100
|
+
// Subscribe first, then snapshot: a settle between the two is queued and arrives in updates once.
|
|
101
|
+
this.#watches.add(queue);
|
|
102
|
+
const initial = entries.map((entry) => snapshotOf(entry));
|
|
103
|
+
return queue.toWatch(initial, () => this.#watches.delete(queue));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
result(ref: HandleRef): HandleOutcome {
|
|
107
|
+
const entry = this.#fence(ref);
|
|
108
|
+
if (!TERMINAL.has(entry.phase)) throw new EvalHandleError("eval_handle_pending", `${ref.id} is still running`);
|
|
109
|
+
if (entry.phase === "succeeded") return { status: "fulfilled", ref: entry.ref, value: entry.value };
|
|
110
|
+
return {
|
|
111
|
+
status: "rejected",
|
|
112
|
+
ref: entry.ref,
|
|
113
|
+
error: entry.error ?? { code: "eval_handle_lost", message: `${ref.id} ended without an outcome` },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cancel(ref: HandleRef): CancelReceipt {
|
|
118
|
+
const entry = this.#fence(ref);
|
|
119
|
+
if (TERMINAL.has(entry.phase)) return { ref: entry.ref, cancelled: false, phase: entry.phase };
|
|
120
|
+
const error = { code: "eval_handle_cancelled", message: `${ref.id} was cancelled` };
|
|
121
|
+
this.#settle(entry, { phase: "cancelled", hostStatus: "cancelled", error });
|
|
122
|
+
entry.controller.abort(new Error(error.message));
|
|
123
|
+
return { ref: entry.ref, cancelled: true, phase: "cancelled" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
output(ref: HandleRef, request: OutputRequest): OutputSnapshot {
|
|
127
|
+
const entry = this.#fence(ref);
|
|
128
|
+
const text =
|
|
129
|
+
entry.phase === "succeeded"
|
|
130
|
+
? typeof entry.value === "string"
|
|
131
|
+
? entry.value
|
|
132
|
+
: JSON.stringify(entry.value)
|
|
133
|
+
: (entry.error?.message ?? "");
|
|
134
|
+
const lines = text.length === 0 ? [] : text.split("\n");
|
|
135
|
+
const total = lines.length;
|
|
136
|
+
const window =
|
|
137
|
+
request.format === "tail"
|
|
138
|
+
? lines.slice(-(request.limit ?? 20))
|
|
139
|
+
: lines.slice(request.offset ?? 0, (request.offset ?? 0) + (request.limit ?? total));
|
|
140
|
+
const offset = request.format === "tail" ? total - window.length : (request.offset ?? 0);
|
|
141
|
+
return { ref: entry.ref, text: window.join("\n"), offset, total, truncated: window.length < total };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Aborts every in-flight completion and closes every watch; afterwards every ref is stale. */
|
|
145
|
+
dispose(): void {
|
|
146
|
+
if (this.#disposed) return;
|
|
147
|
+
this.#disposed = true;
|
|
148
|
+
for (const entry of this.#entries.values()) {
|
|
149
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer);
|
|
150
|
+
if (!TERMINAL.has(entry.phase)) {
|
|
151
|
+
entry.phase = "lost";
|
|
152
|
+
entry.hostStatus = "session-dropped";
|
|
153
|
+
entry.controller.abort(new Error("session generation dropped"));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const queue of this.#watches) queue.close();
|
|
157
|
+
this.#watches.clear();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#assertLive(): void {
|
|
161
|
+
if (this.#disposed) throw new EvalHandleError("eval_handle_stale", "this session generation was dropped");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#fence(ref: HandleRef): CompletionEntry {
|
|
165
|
+
this.#assertLive();
|
|
166
|
+
const entry = this.#entries.get(ref.id);
|
|
167
|
+
if (ref.kind !== "completion" || !entry)
|
|
168
|
+
throw new EvalHandleError("eval_handle_not_found", `no completion handle ${ref.id}`);
|
|
169
|
+
if (ref.run_epoch !== entry.ref.run_epoch)
|
|
170
|
+
throw new EvalHandleError("eval_handle_stale", `${ref.id} run epoch ${ref.run_epoch} is not live`);
|
|
171
|
+
return entry;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#settle(
|
|
175
|
+
entry: CompletionEntry,
|
|
176
|
+
next: { phase: HandlePhase; hostStatus: string; value?: unknown; error?: HandleError },
|
|
177
|
+
): void {
|
|
178
|
+
if (TERMINAL.has(entry.phase)) return;
|
|
179
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer);
|
|
180
|
+
entry.timer = undefined;
|
|
181
|
+
entry.phase = next.phase;
|
|
182
|
+
entry.hostStatus = next.hostStatus;
|
|
183
|
+
if ("value" in next) entry.value = next.value;
|
|
184
|
+
if (next.error) entry.error = next.error;
|
|
185
|
+
entry.revision += 1;
|
|
186
|
+
const snapshot = snapshotOf(entry);
|
|
187
|
+
for (const queue of this.#watches) queue.push(snapshot);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function snapshotOf(entry: CompletionEntry): HandleSnapshot {
|
|
192
|
+
return { ref: entry.ref, phase: entry.phase, host_status: entry.hostStatus, revision: entry.revision };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toHandleError(error: unknown, timedOut: boolean): HandleError {
|
|
196
|
+
if (timedOut) return { code: "completion_deadline", message: DEADLINE_MESSAGE };
|
|
197
|
+
if (error instanceof Error) {
|
|
198
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : "completion_failed";
|
|
199
|
+
return { code, message: error.message };
|
|
200
|
+
}
|
|
201
|
+
return { code: "completion_failed", message: String(error) };
|
|
202
|
+
}
|