@code-yeongyu/senpi 2026.10.18 → 2026.10.19
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 +47 -0
- package/dist/bundle/chunks/{anthropic-messages-DDBWM4JZ.js → anthropic-messages-EMTBHJMM.js} +2 -2
- package/dist/bundle/chunks/{args-2MYPVFPH.js → args-LWR7VFKS.js} +1 -1
- package/dist/bundle/chunks/{azure-openai-responses-3UG5TQU7.js → azure-openai-responses-SNWKFDXK.js} +1 -1
- package/dist/bundle/chunks/chatgpt-subscription.js +1 -1
- package/dist/bundle/chunks/{chunk-SOF44GFZ.js → chunk-35M5MVUZ.js} +149 -145
- package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
- package/dist/bundle/chunks/{chunk-T2MHSEQ2.js → chunk-5AOKDC6G.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYXNQ7YZ.js → chunk-7QPFFAJT.js} +3 -3
- package/dist/bundle/chunks/{chunk-R36U2DJC.js → chunk-AUZXSLZN.js} +1 -1
- package/dist/bundle/chunks/{chunk-PMTG4YNS.js → chunk-BOCN744P.js} +1 -1
- package/dist/bundle/chunks/{chunk-XTAWER7O.js → chunk-CICJOV6I.js} +1 -1
- package/dist/bundle/chunks/{chunk-4UCLDXEF.js → chunk-FM7NDZR5.js} +1 -1
- package/dist/bundle/chunks/{chunk-TBRWPNA4.js → chunk-FSENE7JK.js} +2 -2
- package/dist/bundle/chunks/{chunk-TUD7XRIS.js → chunk-G7LLAQXI.js} +3 -3
- package/dist/bundle/chunks/{chunk-BGD3MZ47.js → chunk-GOKAWZUO.js} +1 -1
- package/dist/bundle/chunks/{chunk-XC6UG5SI.js → chunk-IM6ZWYTW.js} +2 -2
- package/dist/bundle/chunks/{chunk-KHATAB7P.js → chunk-KM4FMQII.js} +1 -1
- package/dist/bundle/chunks/chunk-LAWS3FYQ.js +11 -0
- package/dist/bundle/chunks/{chunk-W6YQYGE2.js → chunk-MVNVDGYF.js} +1 -1
- package/dist/bundle/chunks/{chunk-W4BO6S7F.js → chunk-PMGEQYEP.js} +2 -2
- package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
- package/dist/bundle/chunks/{chunk-5RQNCWS2.js → chunk-QJ6MUFXY.js} +1 -1
- package/dist/bundle/chunks/{chunk-57Q7Q627.js → chunk-QRGAMVEE.js} +1 -1
- package/dist/bundle/chunks/{chunk-W2257FYP.js → chunk-QXEWAKXA.js} +1 -1
- package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
- package/dist/bundle/chunks/{chunk-D2UQYZVK.js → chunk-SHKXJWK3.js} +1 -1
- package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
- package/dist/bundle/chunks/{chunk-S4V72F76.js → chunk-VCD7FD64.js} +1 -1
- package/dist/bundle/chunks/{chunk-MYPTZCPF.js → chunk-VEKNDMUA.js} +1 -1
- package/dist/bundle/chunks/{chunk-IRBAGET4.js → chunk-VPV7ADF3.js} +4 -4
- package/dist/bundle/chunks/{chunk-6DDAEDY7.js → chunk-WEHIEACU.js} +1 -1
- package/dist/bundle/chunks/{chunk-C2HSPFXN.js → chunk-WQLMQGOW.js} +1 -1
- package/dist/bundle/chunks/{chunk-QZIKKUUP.js → chunk-X6TWPBI6.js} +2 -2
- package/dist/bundle/chunks/{chunk-VYWODOAM.js → chunk-YUMRZIKJ.js} +6 -6
- package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
- package/dist/bundle/chunks/{cli-main-MVVPTGMS.js → cli-main-EABMHWFV.js} +1 -1
- package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-H4ZPUBX4.js → cloudflare-workers-ai-system-one-PF7FU63D.js} +1 -1
- package/dist/bundle/chunks/{google-generative-ai-YLAYNQLO.js → google-generative-ai-UFCHYLRE.js} +1 -1
- package/dist/bundle/chunks/{google-vertex-THFXM5R2.js → google-vertex-2FWDY7XX.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-H4ERPCR5.js → help-fast-path-BYWJN6YX.js} +1 -1
- package/dist/bundle/chunks/{host-command-2CMRIKC3.js → host-command-5T34JLBP.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-6Y2EKT4U.js → host-lifecycle-GSRIQP6Z.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +5 -5
- package/dist/bundle/chunks/{host-session-command-MVHY4TKA.js → host-session-command-LF6JODPX.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-2ZQ43N73.js → interactive-mode-GMWHBIUC.js} +1 -1
- package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
- package/dist/bundle/chunks/{mistral-conversations-GUGTVRCL.js → mistral-conversations-QHZLDTSP.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-NO4PIOVG.js → multi-session-host-3J7STKGD.js} +6 -6
- package/dist/bundle/chunks/openai-codex-responses-7YOBPZ2N.js +10 -0
- package/dist/bundle/chunks/{openai-completions-I5N2UIEK.js → openai-completions-HSZPYXEA.js} +1 -1
- package/dist/bundle/chunks/{openai-responses-SGLUJVZW.js → openai-responses-7W2KF3W7.js} +1 -1
- package/dist/bundle/chunks/openrouter.js +1 -1
- package/dist/bundle/chunks/{package-manager-cli-HTZQXYNI.js → package-manager-cli-LWQZSWKL.js} +1 -1
- package/dist/bundle/chunks/radius.js +1 -1
- package/dist/bundle/chunks/{rotation-stream-TOOIK43C.js → rotation-stream-QRRWL73X.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-6ZWGPICX.js → rpc-mode-6LSWRKXR.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-2YW4B655.js → schedule-command-7CXMW4WK.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-XC2LPLCD.js → session-control-endpoint-E265WAIQ.js} +1 -1
- package/dist/bundle/chunks/{session-picker-7W7GIRJT.js → session-picker-XRB3LP5W.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +315 -310
- package/dist/bundle/chunks/{typesafe-system-one-VBWWTJTF.js → typesafe-system-one-YA75435G.js} +1 -1
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-run-continuation.d.ts +24 -0
- package/dist/core/agent-run-continuation.js +57 -0
- package/dist/core/agent-session.d.ts +38 -1
- package/dist/core/agent-session.js +122 -13
- package/dist/core/extension-handler-stall-report.d.ts +11 -0
- package/dist/core/extension-handler-stall-report.js +26 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.d.ts +5 -0
- package/dist/core/extensions/builtin/anthropic-subscription/executable.js +11 -1
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
- package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
- package/dist/core/extensions/handler-budget.d.ts +84 -0
- package/dist/core/extensions/handler-budget.js +136 -0
- package/dist/core/extensions/runner.d.ts +23 -0
- package/dist/core/extensions/runner.js +184 -99
- package/dist/core/footer-data-provider.js +2 -1
- package/dist/core/keybindings.d.ts +1 -1
- package/dist/core/keybindings.js +3 -2
- package/dist/core/model-resolver.js +1 -1
- package/dist/core/pending-input-journal.d.ts +6 -1
- package/dist/core/pending-input-journal.js +90 -34
- package/dist/core/pending-input-public.d.ts +11 -0
- package/dist/core/pending-input-public.js +10 -0
- package/dist/core/pending-input-session-port.d.ts +15 -0
- package/dist/core/pending-input-session-port.js +93 -0
- package/dist/core/pending-input-session.d.ts +108 -0
- package/dist/core/pending-input-session.js +220 -0
- package/dist/core/pending-input-store.d.ts +32 -1
- package/dist/core/pending-input-store.js +74 -26
- package/dist/core/pending-input-types.d.ts +25 -4
- package/dist/core/pending-input.js +36 -18
- package/dist/core/session-entry-file.js +1 -0
- package/dist/core/session-log.js +1 -1
- package/dist/core/session-manager.d.ts +1 -0
- package/dist/core/session-manager.js +5 -1
- package/dist/core/session-move/bundle.d.ts +39 -0
- package/dist/core/session-move/bundle.js +108 -0
- package/dist/core/session-move/export.d.ts +18 -0
- package/dist/core/session-move/export.js +83 -0
- package/dist/core/session-move/finish.d.ts +24 -0
- package/dist/core/session-move/finish.js +81 -0
- package/dist/core/session-move/import.d.ts +32 -0
- package/dist/core/session-move/import.js +124 -0
- package/dist/core/session-move/move-ledger.d.ts +47 -0
- package/dist/core/session-move/move-ledger.js +151 -0
- package/dist/core/session-move/ownership.d.ts +61 -0
- package/dist/core/session-move/ownership.js +89 -0
- package/dist/core/session-move/requeue.d.ts +8 -0
- package/dist/core/session-move/requeue.js +18 -0
- package/dist/core/session-move/write-guard.d.ts +22 -0
- package/dist/core/session-move/write-guard.js +44 -0
- package/dist/core/settings-manager.d.ts +13 -0
- package/dist/core/settings-manager.js +17 -0
- package/dist/core/turn-deadline.d.ts +2 -0
- package/dist/core/turn-deadline.js +10 -3
- package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
- package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
- package/dist/modes/rpc/connection-handler.js +3 -0
- package/dist/modes/rpc/effect-commands.js +3 -4
- package/dist/modes/rpc/host-decision.d.ts +1 -1
- package/dist/modes/rpc/host-decision.js +2 -0
- package/dist/modes/rpc/host-ensure-liveness.d.ts +8 -0
- package/dist/modes/rpc/host-ensure-liveness.js +18 -1
- package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
- package/dist/modes/rpc/host-ensure.js +8 -2
- package/dist/modes/rpc/host-lifecycle-proxy.d.ts +9 -2
- package/dist/modes/rpc/host-lifecycle-proxy.js +36 -8
- package/dist/modes/rpc/host-observe-request.js +1 -0
- package/dist/modes/rpc/multi-session-host.js +3 -33
- package/dist/modes/rpc/rpc-client.d.ts +10 -0
- package/dist/modes/rpc/rpc-client.js +11 -0
- package/dist/modes/rpc/rpc-input-validation.js +8 -0
- package/dist/modes/rpc/rpc-types.d.ts +25 -1
- package/dist/modes/rpc/session-command-activity.js +1 -0
- package/dist/modes/rpc/session-control-actions-data.js +17 -8
- package/dist/modes/rpc/session-held.js +1 -0
- package/dist/modes/rpc/session-release.js +16 -1
- package/dist/modes/rpc/socket-ownership.d.ts +17 -0
- package/dist/modes/rpc/socket-ownership.js +52 -0
- package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
- package/dist/modes/rpc/tool-child/admission.js +81 -0
- package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/admission.test.js +166 -0
- package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
- package/dist/modes/rpc/tool-child/codec.js +137 -0
- package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/codec.test.js +234 -0
- package/dist/modes/rpc/tool-child/context.d.ts +42 -0
- package/dist/modes/rpc/tool-child/context.js +23 -0
- package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
- package/dist/modes/rpc/tool-child/flow-control.js +80 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
- package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
- package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
- package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
- package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
- package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
- package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
- package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
- package/dist/modes/rpc/tool-child/protocol.js +54 -0
- package/dist/utils/wsl.d.ts +2 -2
- package/dist/utils/wsl.js +4 -4
- package/docs/providers.md +2 -0
- package/docs/rpc.md +20 -2
- package/docs/settings.md +1 -0
- package/docs/terminal-setup.md +2 -0
- package/package.json +10 -6
- package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
- package/dist/bundle/chunks/chunk-ES3GHZE5.js +0 -395
- package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
- package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
- package/dist/bundle/chunks/chunk-SA2ZLWBD.js +0 -11
- package/dist/bundle/chunks/openai-codex-responses-5LRKZCBW.js +0 -10
|
@@ -8,7 +8,7 @@ import { HostEnsureRefusedError } from "./host-decision.js";
|
|
|
8
8
|
import { probeSocketReachable } from "./host-probe.js";
|
|
9
9
|
import { hostChildAlive, hostLoopStalled, stallRefusalWindowMs } from "./host-stalled-evidence.js";
|
|
10
10
|
import { loopLagErrorMs } from "./loop-lag-threshold.js";
|
|
11
|
-
import { statSocketIdentity } from "./socket-ownership.js";
|
|
11
|
+
import { socketEntryListenerState, statSocketIdentity } from "./socket-ownership.js";
|
|
12
12
|
/**
|
|
13
13
|
* IS-5: a generation nothing can reach whose loop is measurably stalled is still serving its sessions, so
|
|
14
14
|
* it is neither stopped nor stranded beside a new generation: no signal, no successor, registration intact.
|
|
@@ -47,4 +47,21 @@ export async function publicEndpointAccepts(socket) {
|
|
|
47
47
|
return true;
|
|
48
48
|
return probeSocketReachable(socket, FOREIGN_ENDPOINT_PROBE_TIMEOUT_MS);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether something stands behind a public path that no registration names: the gate before an
|
|
52
|
+
* ensure would start a host there. Unlike `publicEndpointAccepts` it also counts a listener that
|
|
53
|
+
* refuses only because its backlog is full (`socketEntryListenerState`), because a start there
|
|
54
|
+
* could only lose the bind; the registered paths keep `publicEndpointAccepts`, whose "not
|
|
55
|
+
* accepting" leads to the stall check (IS-5).
|
|
56
|
+
*/
|
|
57
|
+
export async function unregisteredEndpointHeld(socket) {
|
|
58
|
+
if (process.platform === "win32" || socket.startsWith("\0"))
|
|
59
|
+
return false;
|
|
60
|
+
const entry = await statSocketIdentity(socket).then((identity) => (identity === undefined ? "absent" : "present"), () => "unknown");
|
|
61
|
+
if (entry === "absent")
|
|
62
|
+
return false;
|
|
63
|
+
if (entry === "unknown")
|
|
64
|
+
return true;
|
|
65
|
+
return (await socketEntryListenerState(socket)).verdict === "live";
|
|
66
|
+
}
|
|
50
67
|
//# sourceMappingURL=host-ensure-liveness.js.map
|
|
@@ -26,6 +26,8 @@ export interface EnsureHostOptions {
|
|
|
26
26
|
readonly stoppingTimeoutMs?: number;
|
|
27
27
|
readonly _test?: {
|
|
28
28
|
readonly readinessTimeoutMs?: number;
|
|
29
|
+
/** How long the existing-host probe waits for get_protocol_info; tests shorten it. */
|
|
30
|
+
readonly existingHostProbeTimeoutMs?: number;
|
|
29
31
|
readonly stopTimeoutMs?: number;
|
|
30
32
|
readonly spawn?: {
|
|
31
33
|
readonly command: string;
|
|
@@ -7,7 +7,7 @@ import { clearHostRegistration, readHostRegistration, writtenByThisProcess, } fr
|
|
|
7
7
|
import { claimHostOwner } from "./host-daemon-state.js";
|
|
8
8
|
import { decideHostAction, HostEnsureRefusedError } from "./host-decision.js";
|
|
9
9
|
import { ensureClient } from "./host-ensure-client.js";
|
|
10
|
-
import { publicEndpointAccepts, refuseIfStalled } from "./host-ensure-liveness.js";
|
|
10
|
+
import { publicEndpointAccepts, refuseIfStalled, unregisteredEndpointHeld } from "./host-ensure-liveness.js";
|
|
11
11
|
import { hostEnsureLockOptions, hostEnsureLockTarget } from "./host-ensure-lock.js";
|
|
12
12
|
import { appendStderr, DEFAULT_READINESS_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, startHost } from "./host-ensure-start.js";
|
|
13
13
|
import { delay, ensureSender, matchesPidFileOrUnknown, resolvePidFileOwnership, STOP_WAIT_BUDGET_MS, stopManagedHost, } from "./host-ensure-stop.js";
|
|
@@ -82,7 +82,7 @@ async function ensureHostLocked(paths, socket, options, stopDeadline = Date.now(
|
|
|
82
82
|
(await hostChildAlive(generationPaths(paths, registered.instanceId))))
|
|
83
83
|
throw new HostEnsureRefusedError(socket, "host_stalled", undefined);
|
|
84
84
|
// A reusable host is held from the connection that proved it compatible, never re-probed later.
|
|
85
|
-
const held = await holdProtocolInfo(socket, EXISTING_HOST_PROBE_TIMEOUT_MS);
|
|
85
|
+
const held = await holdProtocolInfo(socket, testOptions?.existingHostProbeTimeoutMs ?? EXISTING_HOST_PROBE_TIMEOUT_MS);
|
|
86
86
|
const protocol = held?.info;
|
|
87
87
|
if (protocol?.lifecycle === "stopping") {
|
|
88
88
|
held?.hold.release();
|
|
@@ -184,6 +184,12 @@ async function ensureHostLocked(paths, socket, options, stopDeadline = Date.now(
|
|
|
184
184
|
throw new HostEnsureRefusedError(socket, "legacy_host", protocol, legacyRefusal);
|
|
185
185
|
if (stranded !== undefined)
|
|
186
186
|
return startHost(paths, socket, options, stranded.generation + 1);
|
|
187
|
+
// Something no registration names still ACCEPTS at the public path and did not answer the probe.
|
|
188
|
+
// A start can only lose the bind ("address already in use") and exit without a reason, and the
|
|
189
|
+
// holder is not ours to remove (I1): refuse with the reason instead. A stale entry nobody listens
|
|
190
|
+
// behind reads as free here, so the start below still replaces it.
|
|
191
|
+
if (await unregisteredEndpointHeld(socket))
|
|
192
|
+
throw new HostEnsureRefusedError(socket, "endpoint_occupied", protocol, "no host registration names this socket and its listener did not answer get_protocol_info");
|
|
187
193
|
if (registeredHere)
|
|
188
194
|
await clearHostRegistration(paths);
|
|
189
195
|
return startHost(paths, socket, options);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Server } from "node:net";
|
|
2
2
|
import type { HostAdmission } from "./host-admission.ts";
|
|
3
3
|
import type { ClientOccupancy } from "./host-client-occupancy.ts";
|
|
4
|
-
import { type SocketFileIdentity } from "./socket-ownership.ts";
|
|
4
|
+
import { type ListenerState, type SocketFileIdentity } from "./socket-ownership.ts";
|
|
5
5
|
export interface PublicProxyOptions {
|
|
6
6
|
readonly internalSocket: string;
|
|
7
7
|
readonly internalSecret?: Buffer;
|
|
@@ -32,7 +32,14 @@ export declare function adoptPublicSocket(bindSocket: string, publicSocket: stri
|
|
|
32
32
|
*/
|
|
33
33
|
export declare function ensurePublicSocketSecret(publicSocket: string): Promise<Buffer>;
|
|
34
34
|
export declare function waitForListener(socketPath: string, timeoutMs: number, secret?: Uint8Array): Promise<void>;
|
|
35
|
-
|
|
35
|
+
/** Test-only seams for `prepareSocketPath`'s stale-entry check. */
|
|
36
|
+
export interface PrepareSocketPathHooks {
|
|
37
|
+
/** Runs after the entry was probed dead and before it is unlinked. */
|
|
38
|
+
readonly afterStaleProbe?: () => Promise<void>;
|
|
39
|
+
/** Replaces the listener probe, so a test can pin each verdict. */
|
|
40
|
+
readonly listenerState?: (socketPath: string) => Promise<ListenerState>;
|
|
41
|
+
}
|
|
42
|
+
export declare function prepareSocketPath(socketPath: string, hooks?: PrepareSocketPathHooks): Promise<void>;
|
|
36
43
|
export declare function listen(server: Server, socketPath: string, secret?: Uint8Array): Promise<void>;
|
|
37
44
|
export declare function closeServer(server: Server): Promise<void>;
|
|
38
45
|
//# sourceMappingURL=host-lifecycle-proxy.d.ts.map
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* adoption of the public name, and the readiness probes of the private hop. Split out of
|
|
4
4
|
* `host-lifecycle.ts`, which keeps the supervisor's orchestration (senpi#2566).
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { chmod, mkdir, rename, stat, unlink } from "node:fs/promises";
|
|
7
7
|
import { createConnection, createServer } from "node:net";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import { proxyWithAdmission } from "./host-admission-proxy.js";
|
|
10
|
-
import { MAX_SOCKET_PATH_BYTES, statSocketIdentity } from "./socket-ownership.js";
|
|
10
|
+
import { MAX_SOCKET_PATH_BYTES, socketEntryListenerState, statSocketIdentity, } from "./socket-ownership.js";
|
|
11
11
|
import { authenticateSocket, ensureSocketSecret, resolveSocketTransportAddress, sendSocketHandshake, socketSecretPath, } from "./socket-transport.js";
|
|
12
12
|
export function createPublicProxy(options) {
|
|
13
13
|
const { internalSocket, internalSecret, publicSecret, clients } = options;
|
|
@@ -100,8 +100,20 @@ function canConnect(socketPath, secret) {
|
|
|
100
100
|
socket.once("error", () => settle(false));
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
async function stampOf(socketPath) {
|
|
104
|
+
try {
|
|
105
|
+
const entry = await stat(socketPath, { bigint: true });
|
|
106
|
+
return { dev: entry.dev, ino: entry.ino, ctimeNs: entry.ctimeNs, socket: entry.isSocket() };
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
110
|
+
return undefined;
|
|
111
|
+
throw cause;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const sameStamp = (a, b) => a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs;
|
|
115
|
+
export async function prepareSocketPath(socketPath, hooks = {}) {
|
|
116
|
+
if (process.platform === "win32" || socketPath.startsWith("\0"))
|
|
105
117
|
return;
|
|
106
118
|
// A path the kernel would truncate binds a DIFFERENT endpoint than the one every client was
|
|
107
119
|
// told about, and the failure surfaces much later as "the host does not answer". Refuse here.
|
|
@@ -109,17 +121,33 @@ export async function prepareSocketPath(socketPath) {
|
|
|
109
121
|
throw new Error(`${socketPath}: socket path is ${Buffer.byteLength(socketPath)} bytes, over the ${MAX_SOCKET_PATH_BYTES}-byte limit.`);
|
|
110
122
|
}
|
|
111
123
|
await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
124
|
+
const probed = await stampOf(socketPath);
|
|
125
|
+
if (probed === undefined)
|
|
126
|
+
return;
|
|
127
|
+
const inUse = (evidence) => new Error(`${socketPath}: address already in use by a live server (${evidence}).`);
|
|
128
|
+
// Linux refuses a connect to a regular file too; never remove what is not a socket.
|
|
129
|
+
if (!probed.socket)
|
|
130
|
+
throw inUse("the entry is not a socket");
|
|
131
|
+
const state = await (hooks.listenerState ?? socketEntryListenerState)(socketPath);
|
|
132
|
+
if (state.verdict === "gone")
|
|
133
|
+
return;
|
|
134
|
+
if (state.verdict === "live")
|
|
135
|
+
throw inUse(state.evidence);
|
|
136
|
+
await hooks.afterStaleProbe?.();
|
|
137
|
+
// Something may have replaced the entry since the probe; unlink only the entry probed dead.
|
|
138
|
+
const current = await stampOf(socketPath);
|
|
139
|
+
if (current === undefined)
|
|
140
|
+
return;
|
|
141
|
+
if (!sameStamp(probed, current))
|
|
142
|
+
throw inUse("the entry changed after it was probed");
|
|
112
143
|
try {
|
|
113
|
-
await
|
|
144
|
+
await unlink(socketPath);
|
|
114
145
|
}
|
|
115
146
|
catch (cause) {
|
|
116
147
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
117
148
|
return;
|
|
118
149
|
throw cause;
|
|
119
150
|
}
|
|
120
|
-
if (await canConnect(socketPath))
|
|
121
|
-
throw new Error(`${socketPath}: address already in use by a live server.`);
|
|
122
|
-
await unlink(socketPath);
|
|
123
151
|
}
|
|
124
152
|
export function listen(server, socketPath, secret) {
|
|
125
153
|
return new Promise((resolve, reject) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { chmod } from "node:fs/promises";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { envValue } from "../../core/brand.js";
|
|
5
5
|
import { HostMcpRegistry } from "../../core/extensions/builtin/mcp/host-registry.js";
|
|
@@ -16,6 +16,7 @@ import { performIdleHandover } from "./host-handover-wire.js";
|
|
|
16
16
|
import { EXPECTED_RUNTIME_BUILD_ID_ENV } from "./host-idle-handover.js";
|
|
17
17
|
import { RPC_CLOSE_GRACE_MS_ENV, resolveHostIdlePolicy } from "./host-idle-policy.js";
|
|
18
18
|
import { parseIdleExitMs } from "./host-lifecycle.js";
|
|
19
|
+
import { prepareSocketPath } from "./host-lifecycle-proxy.js";
|
|
19
20
|
import { createZeroSessionTrim, startHostObservers } from "./host-observers.js";
|
|
20
21
|
import { runAsHostGenerationProcess } from "./host-process-role.js";
|
|
21
22
|
import { createEndpointReservations } from "./host-reservations.js";
|
|
@@ -539,34 +540,6 @@ function resolveSocketPath(value, agentDir) {
|
|
|
539
540
|
function formatSocketAddress(socketPath) {
|
|
540
541
|
return socketPath.startsWith("\0") ? `unix://@${socketPath.slice(1)}` : `unix://${socketPath}`;
|
|
541
542
|
}
|
|
542
|
-
async function prepareSocketPath(socketPath) {
|
|
543
|
-
if (process.platform === "win32" || socketPath.startsWith("\0"))
|
|
544
|
-
return;
|
|
545
|
-
await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
546
|
-
try {
|
|
547
|
-
await access(socketPath);
|
|
548
|
-
}
|
|
549
|
-
catch (cause) {
|
|
550
|
-
if (isNodeErrorCode(cause, "ENOENT"))
|
|
551
|
-
return;
|
|
552
|
-
throw cause;
|
|
553
|
-
}
|
|
554
|
-
if (await probeSocket(socketPath))
|
|
555
|
-
throw new Error(`${socketPath}: address already in use by a live server.`);
|
|
556
|
-
await unlink(socketPath);
|
|
557
|
-
}
|
|
558
|
-
function probeSocket(socketPath) {
|
|
559
|
-
return new Promise((resolve) => {
|
|
560
|
-
const socket = createConnection(resolveSocketTransportAddress(socketPath, process.platform));
|
|
561
|
-
const settle = (live) => {
|
|
562
|
-
socket.destroy();
|
|
563
|
-
resolve(live);
|
|
564
|
-
};
|
|
565
|
-
socket.once("connect", () => settle(true));
|
|
566
|
-
socket.once("error", () => settle(false));
|
|
567
|
-
socket.setTimeout(1_000, () => settle(false));
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
543
|
function listen(server, socketPath, secret) {
|
|
571
544
|
return new Promise((resolve, reject) => {
|
|
572
545
|
server.once("error", reject);
|
|
@@ -595,9 +568,6 @@ function closeServer(server) {
|
|
|
595
568
|
function hostLog(message) {
|
|
596
569
|
process.stderr.write(`senpi rpc host: ${message}\n`);
|
|
597
570
|
}
|
|
598
|
-
function isNodeErrorCode(cause, code) {
|
|
599
|
-
return cause instanceof Error && "code" in cause && cause.code === code;
|
|
600
|
-
}
|
|
601
571
|
function registerShutdownSignals(shutdown) {
|
|
602
572
|
for (const signal of process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"]) {
|
|
603
573
|
process.on(signal, () => {
|
|
@@ -86,6 +86,11 @@ export type RpcEffectStatusAnswer = Extract<RpcResponse, {
|
|
|
86
86
|
command: "get_effect_status";
|
|
87
87
|
success: true;
|
|
88
88
|
}>["data"];
|
|
89
|
+
/** `get_pending_inputs`: pending inputs in durable custody, and an outcome for each known id. */
|
|
90
|
+
export type RpcPendingInputsAnswer = Extract<RpcResponse, {
|
|
91
|
+
command: "get_pending_inputs";
|
|
92
|
+
success: true;
|
|
93
|
+
}>["data"];
|
|
89
94
|
export declare class RpcClientOpenInFlightError extends Error {
|
|
90
95
|
readonly code: "open_session_in_flight";
|
|
91
96
|
constructor();
|
|
@@ -251,6 +256,11 @@ export declare class RpcClient {
|
|
|
251
256
|
getEffectStatus(effectId: string, options?: {
|
|
252
257
|
readonly sessionPath?: string;
|
|
253
258
|
}): Promise<RpcEffectStatusAnswer>;
|
|
259
|
+
/**
|
|
260
|
+
* The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each id in
|
|
261
|
+
* `knownClientMessageIds` (at most 64), consistent through `throughCursor`.
|
|
262
|
+
*/
|
|
263
|
+
getPendingInputs(knownClientMessageIds?: readonly string[]): Promise<RpcPendingInputsAnswer>;
|
|
254
264
|
/** Abort with host refusals and transport loss surfaced to machine callers. */
|
|
255
265
|
abortStrict(): Promise<void>;
|
|
256
266
|
abortCompaction(): Promise<void>;
|
|
@@ -480,6 +480,17 @@ export class RpcClient {
|
|
|
480
480
|
};
|
|
481
481
|
return this.getData(await this.send(command, options.sessionPath === undefined));
|
|
482
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each id in
|
|
485
|
+
* `knownClientMessageIds` (at most 64), consistent through `throughCursor`.
|
|
486
|
+
*/
|
|
487
|
+
async getPendingInputs(knownClientMessageIds) {
|
|
488
|
+
const command = {
|
|
489
|
+
type: "get_pending_inputs",
|
|
490
|
+
...(knownClientMessageIds === undefined ? {} : { knownClientMessageIds: [...knownClientMessageIds] }),
|
|
491
|
+
};
|
|
492
|
+
return this.getData(await this.send(command));
|
|
493
|
+
}
|
|
483
494
|
/** Abort with host refusals and transport loss surfaced to machine callers. */
|
|
484
495
|
async abortStrict() {
|
|
485
496
|
this.getData(await this.send({ type: "abort" }));
|
|
@@ -119,6 +119,14 @@ export function rpcCommandPayloadError(command) {
|
|
|
119
119
|
!(typeof value.effectId === "string" && value.effectId.length > 0 && value.effectId.length <= 256)) {
|
|
120
120
|
return "effectId must be a non-empty string of at most 256 characters.";
|
|
121
121
|
}
|
|
122
|
+
if (value.type === "get_pending_inputs" && value.knownClientMessageIds !== undefined) {
|
|
123
|
+
const ids = value.knownClientMessageIds;
|
|
124
|
+
if (!Array.isArray(ids) ||
|
|
125
|
+
ids.length > 64 ||
|
|
126
|
+
!ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 256)) {
|
|
127
|
+
return "knownClientMessageIds must be an array of at most 64 non-empty strings of at most 256 characters.";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
122
130
|
if (value.type === "get_effect_status" && value.sessionPath !== undefined && typeof value.sessionPath !== "string") {
|
|
123
131
|
return "sessionPath must be a string.";
|
|
124
132
|
}
|
|
@@ -19,6 +19,7 @@ import type { EngineOrdinal } from "../../core/engine-build-identity.ts";
|
|
|
19
19
|
import type { ServiceTier } from "../../core/extensions/builtin/service-tier.ts";
|
|
20
20
|
import type { ContextUsage, SessionControlAdmission, SessionKind } from "../../core/extensions/types.ts";
|
|
21
21
|
import type { ResolvedMessagePath } from "../../core/message-path-resolution.ts";
|
|
22
|
+
import type { PendingInputSnapshot, PendingInputUpdate } from "../../core/pending-input-session.ts";
|
|
22
23
|
import type { SessionEntry, SessionMessageEntry, SessionTreeNode, UsageTotals } from "../../core/session-manager.ts";
|
|
23
24
|
import type { SourceInfo } from "../../core/source-info.ts";
|
|
24
25
|
import type { ApiKeyVerification } from "../../core/verify-api-key.ts";
|
|
@@ -165,6 +166,16 @@ type RpcSessionCommand = {
|
|
|
165
166
|
type: "get_effect_status";
|
|
166
167
|
effectId: string;
|
|
167
168
|
sessionPath?: string;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each listed id,
|
|
172
|
+
* consistent through `throughCursor` (senpi#3549). Subscribe to `pending_input_update` first, then apply this
|
|
173
|
+
* snapshot and only updates after its cursor. At most 64 ids.
|
|
174
|
+
*/
|
|
175
|
+
| {
|
|
176
|
+
id?: string;
|
|
177
|
+
type: "get_pending_inputs";
|
|
178
|
+
knownClientMessageIds?: string[];
|
|
168
179
|
} | {
|
|
169
180
|
id?: string;
|
|
170
181
|
type: "set_model";
|
|
@@ -689,7 +700,10 @@ export type RpcCommand = (RpcSessionCommand & {
|
|
|
689
700
|
*/
|
|
690
701
|
type: "release_session";
|
|
691
702
|
sessionId: string;
|
|
692
|
-
|
|
703
|
+
/** `move` seals the file for a move to another device (`moveId` required); writes are refused here after. */
|
|
704
|
+
reason: "takeover" | "move";
|
|
705
|
+
moveId?: string;
|
|
706
|
+
target?: string;
|
|
693
707
|
interrupt?: boolean;
|
|
694
708
|
force?: boolean;
|
|
695
709
|
} | {
|
|
@@ -1202,6 +1216,12 @@ export type RpcResponse = {
|
|
|
1202
1216
|
found: false;
|
|
1203
1217
|
searched: "complete";
|
|
1204
1218
|
};
|
|
1219
|
+
} | {
|
|
1220
|
+
id?: string;
|
|
1221
|
+
type: "response";
|
|
1222
|
+
command: "get_pending_inputs";
|
|
1223
|
+
success: true;
|
|
1224
|
+
data: PendingInputSnapshot;
|
|
1205
1225
|
} | {
|
|
1206
1226
|
id?: string;
|
|
1207
1227
|
type: "response";
|
|
@@ -1824,6 +1844,10 @@ export interface RpcHighReasoningWarningEvent {
|
|
|
1824
1844
|
provider: string;
|
|
1825
1845
|
thinkingLevel: ThinkingLevel;
|
|
1826
1846
|
}
|
|
1847
|
+
/** A pending input in durable custody changed; `changes` are complete projections, in cursor order. */
|
|
1848
|
+
export type RpcPendingInputUpdateEvent = {
|
|
1849
|
+
type: "pending_input_update";
|
|
1850
|
+
} & PendingInputUpdate;
|
|
1827
1851
|
/** Emitted after explicit skill tokens are expanded for a user-authored request. */
|
|
1828
1852
|
export interface RpcSkillInvocationEvent {
|
|
1829
1853
|
type: "skill_invocation";
|
|
@@ -6,15 +6,24 @@
|
|
|
6
6
|
import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
|
|
7
7
|
import { getSupportedThinkingLevels } from "../../core/thinking-levels.js";
|
|
8
8
|
export async function availableModelsData(session) {
|
|
9
|
-
const
|
|
9
|
+
const registry = session.modelRegistry;
|
|
10
|
+
const models = await registry.modelRuntime.getAvailable();
|
|
10
11
|
return {
|
|
11
|
-
models: models.map((model) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
models: models.map((model) => {
|
|
13
|
+
// A models.json or extension definition can declare the priority pairing without putting it on
|
|
14
|
+
// the Model object; clients must see the values the session itself acts on (#3666).
|
|
15
|
+
const serviceTier = registry.getServiceTier(model);
|
|
16
|
+
const upstreamModelId = registry.getUpstreamModelId(model);
|
|
17
|
+
return {
|
|
18
|
+
...model,
|
|
19
|
+
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
20
|
+
...(upstreamModelId === undefined ? {} : { upstreamModelId }),
|
|
21
|
+
supportedThinkingLevels: getSupportedThinkingLevels(model),
|
|
22
|
+
supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
|
|
23
|
+
thinkingEnabled: session.thinkingLevel !== "off",
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}),
|
|
18
27
|
};
|
|
19
28
|
}
|
|
20
29
|
/** The refusal of a level the active model cannot run; callers match this text. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SessionMovedAwayError } from "../../core/session-move/ownership.js";
|
|
1
2
|
import { isHandoffBusy } from "./handoff-activity.js";
|
|
2
3
|
import { RPC_ERROR_ATTACHED, RPC_ERROR_HOST_DRAINING, RPC_ERROR_INVALID_RELEASE_REASON, RPC_ERROR_RELEASE_FAILED, RPC_ERROR_RELEASE_UNSUPPORTED, RPC_ERROR_SESSION_BUSY, RPC_ERROR_SESSION_CLOSING, RPC_ERROR_TURN_ACTIVE, } from "./rpc-types.js";
|
|
3
4
|
import { abortAndSettle, NOTHING_DROPPED, takeQueuedInput, } from "./session-release-interrupt.js";
|
|
@@ -8,7 +9,8 @@ export const RELEASED_ADMISSION_CLOSED = "release_session handed this session to
|
|
|
8
9
|
/** `errorData.hint` of a refusal whose `busy` names `queued`: only an interrupt hands that input back. */
|
|
9
10
|
export const RELEASE_QUEUED_HINT = "queued user input is still owed a turn; release with interrupt: true to take it out and receive it in dropped.user_messages";
|
|
10
11
|
export async function releaseSession(port, command) {
|
|
11
|
-
|
|
12
|
+
const validMove = command.reason === "move" && typeof command.moveId === "string" && command.moveId !== "";
|
|
13
|
+
if (command.reason !== "takeover" && !validMove)
|
|
12
14
|
return refusal(command.id, RPC_ERROR_INVALID_RELEASE_REASON);
|
|
13
15
|
if (port.draining())
|
|
14
16
|
return refusal(command.id, RPC_ERROR_HOST_DRAINING);
|
|
@@ -30,6 +32,8 @@ export async function releaseSession(port, command) {
|
|
|
30
32
|
admission.close(RELEASED_ADMISSION_CLOSED);
|
|
31
33
|
dropped = takeQueuedInput(first.session);
|
|
32
34
|
await abortAndSettle(port, command.sessionId, first.session);
|
|
35
|
+
// What the clear retired is on disk before `session_released` seals the file (senpi#3549).
|
|
36
|
+
await first.session.whenQueueClearRecorded();
|
|
33
37
|
}
|
|
34
38
|
// A never-written session gets its file here, before the final check: its `session_released` entry
|
|
35
39
|
// must reach disk, and a header write that fails is this release's failure, never a stray rejection.
|
|
@@ -41,6 +45,10 @@ export async function releaseSession(port, command) {
|
|
|
41
45
|
return answer;
|
|
42
46
|
}
|
|
43
47
|
catch (cause) {
|
|
48
|
+
if (cause instanceof SessionMovedAwayError) {
|
|
49
|
+
answer = refusal(command.id, cause.code, { detail: cause.message });
|
|
50
|
+
return answer;
|
|
51
|
+
}
|
|
44
52
|
answer = refusal(command.id, RPC_ERROR_RELEASE_FAILED, {
|
|
45
53
|
detail: cause instanceof Error ? cause.message : String(cause),
|
|
46
54
|
...(interrupted ? { interrupted: true, dropped } : {}),
|
|
@@ -78,6 +86,13 @@ async function claimAndRelease(port, command, interrupted, dropped) {
|
|
|
78
86
|
// On disk or not at all: a failed write leaves no entry a later append could chain onto.
|
|
79
87
|
ready.session.sessionManager.appendCustomEntry(SESSION_RELEASED_ENTRY_TYPE, {
|
|
80
88
|
reason: command.reason,
|
|
89
|
+
...(command.reason === "move"
|
|
90
|
+
? {
|
|
91
|
+
move_id: command.moveId,
|
|
92
|
+
target: command.target ?? "",
|
|
93
|
+
session_id: ready.session.sessionManager.getSessionId(),
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
81
96
|
interrupted,
|
|
82
97
|
attachments: ready.attachments,
|
|
83
98
|
host_instance: port.hostInstance ?? null,
|
|
@@ -26,6 +26,23 @@ export declare const MAX_SOCKET_PATH_BYTES = 103;
|
|
|
26
26
|
* public path: that path belongs to the running host until the successor is proven to work.
|
|
27
27
|
*/
|
|
28
28
|
export declare function generationBindPath(publicSocket: string, generation: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* What one failed connect says about the process behind a socket entry. Only a refusal can mean
|
|
31
|
+
* "nobody": on Linux a full backlog answers EAGAIN, so ECONNREFUSED is proof of absence there,
|
|
32
|
+
* but macOS (and the BSDs) refuse a live listener whose backlog is full, so a refusal there needs
|
|
33
|
+
* a second witness. ENOENT means the entry is gone; every other error is a live owner (I1).
|
|
34
|
+
*/
|
|
35
|
+
export type ConnectVerdict = "live" | "dead" | "gone" | "confirm-holder";
|
|
36
|
+
export declare function classifyConnectError(code: string | undefined, platform: NodeJS.Platform): ConnectVerdict;
|
|
37
|
+
/** Whether any process holds the entry open: pids from `lsof -t`, none, or unknown. */
|
|
38
|
+
export type SocketHolders = (socketPath: string) => Promise<readonly number[] | undefined>;
|
|
39
|
+
export declare const lsofSocketHolders: SocketHolders;
|
|
40
|
+
/** Whether a live process stands behind a socket entry, and the evidence for the answer. */
|
|
41
|
+
export interface ListenerState {
|
|
42
|
+
readonly verdict: "live" | "dead" | "gone";
|
|
43
|
+
readonly evidence: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function socketEntryListenerState(socketPath: string, platform?: NodeJS.Platform, holders?: SocketHolders): Promise<ListenerState>;
|
|
29
46
|
/** Reads the current identity of a socket path entry; `undefined` once absent. */
|
|
30
47
|
export declare function statSocketIdentity(socketPath: string): Promise<SocketFileIdentity | undefined>;
|
|
31
48
|
/**
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* capture the bound entry's filesystem identity (dev + ino) at listen time and
|
|
11
11
|
* unlink only while the path still stat-matches that identity.
|
|
12
12
|
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
13
14
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
15
|
+
import { createConnection } from "node:net";
|
|
14
16
|
import { dirname } from "node:path";
|
|
17
|
+
import { resolveSocketTransportAddress } from "./socket-transport.js";
|
|
15
18
|
/**
|
|
16
19
|
* Name of the sidecar, inside the lifecycle supervisor's private scratch
|
|
17
20
|
* directory, that records which public socket entry that supervisor bound. The
|
|
@@ -38,6 +41,55 @@ export function generationBindPath(publicSocket, generation) {
|
|
|
38
41
|
function sameSocketIdentity(a, b) {
|
|
39
42
|
return a.dev === b.dev && a.ino === b.ino;
|
|
40
43
|
}
|
|
44
|
+
export function classifyConnectError(code, platform) {
|
|
45
|
+
if (code === "ENOENT")
|
|
46
|
+
return "gone";
|
|
47
|
+
if (code === "ECONNREFUSED")
|
|
48
|
+
return platform === "linux" ? "dead" : "confirm-holder";
|
|
49
|
+
return "live";
|
|
50
|
+
}
|
|
51
|
+
const HOLDER_PROBE_TIMEOUT_MS = 5_000;
|
|
52
|
+
export const lsofSocketHolders = (socketPath) => new Promise((resolve) => {
|
|
53
|
+
execFile("lsof", ["-t", "--", socketPath], { timeout: HOLDER_PROBE_TIMEOUT_MS }, (error, stdout) => {
|
|
54
|
+
const pids = String(stdout)
|
|
55
|
+
.split(/\s+/)
|
|
56
|
+
.filter((part) => /^\d+$/.test(part))
|
|
57
|
+
.map(Number);
|
|
58
|
+
if (pids.length > 0)
|
|
59
|
+
return resolve(pids);
|
|
60
|
+
// lsof exits 1 with no output when nothing holds the file; anything else proves nothing.
|
|
61
|
+
const exitCode = error === null ? undefined : Reflect.get(error, "code");
|
|
62
|
+
resolve(error === null || exitCode === 1 ? [] : undefined);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
const CONNECT_PROBE_TIMEOUT_MS = 2_000;
|
|
66
|
+
/** One connect to the entry: `connected`, or the error code it failed with (`timeout` when it hung). */
|
|
67
|
+
function connectOnce(socketPath) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const socket = createConnection(resolveSocketTransportAddress(socketPath, process.platform));
|
|
70
|
+
const settle = (value) => {
|
|
71
|
+
socket.destroy();
|
|
72
|
+
resolve(value);
|
|
73
|
+
};
|
|
74
|
+
socket.setTimeout(CONNECT_PROBE_TIMEOUT_MS, () => settle({ code: "timeout" }));
|
|
75
|
+
socket.once("connect", () => settle({ connected: true }));
|
|
76
|
+
socket.once("error", (cause) => settle({ code: cause.code }));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export async function socketEntryListenerState(socketPath, platform = process.platform, holders = lsofSocketHolders) {
|
|
80
|
+
const attempt = await connectOnce(socketPath);
|
|
81
|
+
if ("connected" in attempt)
|
|
82
|
+
return { verdict: "live", evidence: "accepting connections" };
|
|
83
|
+
const verdict = classifyConnectError(attempt.code, platform);
|
|
84
|
+
if (verdict !== "confirm-holder")
|
|
85
|
+
return { verdict, evidence: `connect ${attempt.code ?? "failed"}` };
|
|
86
|
+
const pids = await holders(socketPath);
|
|
87
|
+
if (pids === undefined)
|
|
88
|
+
return { verdict: "live", evidence: "connect ECONNREFUSED, holders unknown" };
|
|
89
|
+
if (pids.length > 0)
|
|
90
|
+
return { verdict: "live", evidence: `connect ECONNREFUSED, held open by pid ${pids.join(",")}` };
|
|
91
|
+
return { verdict: "dead", evidence: "connect ECONNREFUSED, held by nobody" };
|
|
92
|
+
}
|
|
41
93
|
/** Reads the current identity of a socket path entry; `undefined` once absent. */
|
|
42
94
|
export async function statSocketIdentity(socketPath) {
|
|
43
95
|
try {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ToolChildMessageOf } from "./protocol.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The one host-call capacity both sides enforce: `HOST_CALL_ACTIVE` running plus `HOST_CALL_WAITING` queued (264).
|
|
4
|
+
* The child admits against it before sending, the server admits against it on receipt; there is no second constant.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hostCallCapacity(): number;
|
|
7
|
+
export type HostCallRefusalCode = "host_call_capacity" | "host_call_duplicate";
|
|
8
|
+
export type HostCallDecision = {
|
|
9
|
+
state: "active";
|
|
10
|
+
} | {
|
|
11
|
+
state: "waiting";
|
|
12
|
+
} | {
|
|
13
|
+
state: "refused";
|
|
14
|
+
frame: ToolChildMessageOf<"host_call_refused">;
|
|
15
|
+
};
|
|
16
|
+
/** Lossless host-call admission: a call is running, queued FIFO, or refused with a frame naming its `callId`. */
|
|
17
|
+
export declare class HostCallAdmission {
|
|
18
|
+
#private;
|
|
19
|
+
get active(): number;
|
|
20
|
+
get waiting(): number;
|
|
21
|
+
submit(callId: string): HostCallDecision;
|
|
22
|
+
/** Ends a call (or withdraws a queued one) and returns the call promoted into the freed active slot, if any. */
|
|
23
|
+
settle(callId: string): string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export type JobRefusalCode = "tool_child_jobs_full" | "tool_child_nested_full" | "tool_child_parent_unknown" | "tool_child_job_duplicate";
|
|
26
|
+
export type JobDecision = {
|
|
27
|
+
admitted: true;
|
|
28
|
+
} | {
|
|
29
|
+
admitted: false;
|
|
30
|
+
frame: ToolChildMessageOf<"job_refused">;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Per-child job budget. Ordinary jobs are capped at `TOOL_CHILD_LIMITS.jobs`. A nested job (started on behalf of a
|
|
34
|
+
* running job, e.g. a tool an eval cell calls) is admitted past that cap, so a full child cannot deadlock its own
|
|
35
|
+
* callers; nested jobs arrive through host calls and are bounded by `hostCallCapacity()`.
|
|
36
|
+
*/
|
|
37
|
+
export declare class JobAdmission {
|
|
38
|
+
#private;
|
|
39
|
+
admit(jobId: string, parentJobId?: string): JobDecision;
|
|
40
|
+
finish(jobId: string): void;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=admission.d.ts.map
|