@code-yeongyu/senpi 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 +34 -0
- package/dist/bundle/chunks/{anthropic-messages-4MNFPQ66.js → anthropic-messages-CVXI6CLT.js} +1 -1
- package/dist/bundle/chunks/{app-server-command-GNTX4MZT.js → app-server-command-MJOYE5ZS.js} +1 -1
- package/dist/bundle/chunks/{chunk-BE66QJ3Z.js → chunk-4GUQVY4Z.js} +1 -1
- package/dist/bundle/chunks/{chunk-3EE6G4G7.js → chunk-5MG76BCM.js} +13 -13
- package/dist/bundle/chunks/{chunk-ESSQUSRC.js → chunk-BTYFICUC.js} +1 -1
- package/dist/bundle/chunks/{chunk-4MJKBXI3.js → chunk-CB77NQMN.js} +8 -4
- package/dist/bundle/chunks/{chunk-6JXPZACL.js → chunk-CM5QTRC2.js} +1 -1
- package/dist/bundle/chunks/{chunk-D2W3MXRF.js → chunk-LSE7KE2C.js} +1 -1
- package/dist/bundle/chunks/{chunk-22DAHAHO.js → chunk-LUWWM7GA.js} +2 -2
- package/dist/bundle/chunks/{chunk-HKG7XL4H.js → chunk-MJGHZT4N.js} +4 -4
- package/dist/bundle/chunks/{chunk-7A3WRAKP.js → chunk-Q2ZTPMWD.js} +1 -1
- package/dist/bundle/chunks/{chunk-6KQGOVQG.js → chunk-VJJ53ORU.js} +1 -1
- package/dist/bundle/chunks/chunk-X6UDRO7J.js +14 -0
- package/dist/bundle/chunks/{chunk-XYDZ3LIE.js → chunk-YI7SPFDN.js} +2 -2
- package/dist/bundle/chunks/chunk-ZJHRDQCH.js +2 -0
- package/dist/bundle/chunks/cli-main-GWVLFOIP.js +3 -0
- package/dist/bundle/chunks/{help-fast-path-FUMH5NSA.js → help-fast-path-JBQLYO7W.js} +1 -1
- package/dist/bundle/chunks/host-command-HTYYQBSG.js +2 -0
- package/dist/bundle/chunks/{host-lifecycle-7OVWGVIZ.js → host-lifecycle-XZF6IKKO.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +3 -3
- package/dist/bundle/chunks/interactive-mode-YLM2U7IX.js +2 -0
- package/dist/bundle/chunks/{multi-session-host-QLLTPWIM.js → multi-session-host-KMCBBPTV.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-VRCAO7RV.js → package-manager-cli-3UIJ4RXY.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-7E37KOCX.js → rotation-stream-ZJQ6I2RZ.js} +1 -1
- package/dist/bundle/chunks/rpc-mode-PUYBZG27.js +2 -0
- package/dist/bundle/chunks/{schedule-command-QTYSUYO2.js → schedule-command-CVAPK2WQ.js} +1 -1
- package/dist/bundle/chunks/{sdk-EO76I33H.js → sdk-U52J75K2.js} +50 -50
- package/dist/bundle/chunks/session-control-endpoint-THEF3K7M.js +3 -0
- package/dist/bundle/chunks/{session-picker-VTTRFGE7.js → session-picker-N463VCDI.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +79 -75
- package/dist/bundle/cli.js +1 -1
- 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-session.d.ts +7 -2
- package/dist/core/agent-session.js +31 -5
- package/dist/core/continue-from-leaf.d.ts +21 -0
- package/dist/core/continue-from-leaf.js +39 -0
- package/dist/core/extensions/eval-handle-host.d.ts +82 -0
- package/dist/core/extensions/eval-handle-host.js +28 -0
- package/dist/core/extensions/loader.js +5 -0
- package/dist/core/extensions/runner.js +4 -0
- package/dist/core/extensions/types.d.ts +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modes/interactive/session-control-registry.js +2 -0
- package/dist/modes/rpc/host-daemon-registration.d.ts +9 -0
- package/dist/modes/rpc/host-daemon-registration.js +2 -4
- package/dist/modes/rpc/host-ensure-start.js +2 -0
- package/dist/modes/rpc/host-handoff-unregistered.d.ts +24 -0
- package/dist/modes/rpc/host-handoff-unregistered.js +50 -0
- package/dist/modes/rpc/host-handoff.d.ts +7 -2
- package/dist/modes/rpc/host-handoff.js +7 -3
- package/dist/modes/rpc/host-legacy.d.ts +3 -0
- package/dist/modes/rpc/host-legacy.js +10 -5
- package/dist/modes/rpc/host-lifecycle.js +5 -28
- package/dist/modes/rpc/host-probe.d.ts +12 -0
- package/dist/modes/rpc/host-probe.js +53 -5
- package/dist/modes/rpc/host-run-activity.d.ts +13 -0
- package/dist/modes/rpc/host-run-activity.js +39 -0
- package/dist/modes/rpc/host-runner.js +1 -1
- package/dist/modes/rpc/host-successor.d.ts +5 -4
- package/dist/modes/rpc/host-successor.js +11 -4
- package/docs/rpc.md +10 -2
- package/package.json +7 -7
- package/dist/bundle/chunks/chunk-QLIW3YMI.js +0 -14
- package/dist/bundle/chunks/cli-main-2C3YPHRE.js +0 -3
- package/dist/bundle/chunks/host-command-N6XS5O67.js +0 -2
- package/dist/bundle/chunks/interactive-mode-CM4C4ZFY.js +0 -2
- package/dist/bundle/chunks/rpc-mode-2OPE6J7P.js +0 -2
- package/dist/bundle/chunks/session-control-endpoint-SOG4XFF7.js +0 -3
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A handoff away from a host that no layout-2 registration proves (#2701).
|
|
3
|
+
*
|
|
4
|
+
* After an upgrade the old host is often still serving the very socket the updated client uses, and the
|
|
5
|
+
* only evidence of who it is may be a flat pre-layout-2 record - or nothing provable at all. Refusing
|
|
6
|
+
* such a host with `unknown_owner` left the client unable to replace it until somebody drained it by
|
|
7
|
+
* hand. An IDLE one is replaced instead; one that holds work never is.
|
|
8
|
+
*
|
|
9
|
+
* - The sessions are counted over a connection that stays open, and counted again over that same
|
|
10
|
+
* connection once the successor owns the socket: the recount reaches the old process, and a session
|
|
11
|
+
* it accepted in between is seen there.
|
|
12
|
+
* - A flat record that proves the process is the only licence to signal it, and the signal is a DRAIN
|
|
13
|
+
* (SIGUSR1), sent only when the recount still finds no session. A drain ends no work either way.
|
|
14
|
+
* - Without a provable owner nothing is signalled: the old host notices that it lost the public entry
|
|
15
|
+
* and drains itself (`host-supersession.ts`). That predecessor is named in one warning line, so an
|
|
16
|
+
* old host that outlives the handoff can still be identified.
|
|
17
|
+
*/
|
|
18
|
+
import type { HostDaemonPaths } from "./host-daemon-paths.ts";
|
|
19
|
+
import type { HostProtocolInfo } from "./host-decision.ts";
|
|
20
|
+
import type { HandoffHostOptions, HandoffResult } from "./host-handoff.ts";
|
|
21
|
+
/** One session count; an unregistered host is counted twice (before the swap and over the held connection after). */
|
|
22
|
+
export declare const SESSION_COUNT_TIMEOUT_MS = 10000;
|
|
23
|
+
export declare function handoffUnregisteredHost(options: HandoffHostOptions, paths: HostDaemonPaths, host: HostProtocolInfo): Promise<HandoffResult>;
|
|
24
|
+
//# sourceMappingURL=host-handoff-unregistered.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { busyLegacyHostDetail, describeSessions, provenLegacyOwner } from "./host-legacy.js";
|
|
2
|
+
import { holdSessionCount } from "./host-probe.js";
|
|
3
|
+
import { startSuccessor } from "./host-successor.js";
|
|
4
|
+
/** One session count; an unregistered host is counted twice (before the swap and over the held connection after). */
|
|
5
|
+
export const SESSION_COUNT_TIMEOUT_MS = 10_000;
|
|
6
|
+
export async function handoffUnregisteredHost(options, paths, host) {
|
|
7
|
+
const counted = await holdSessionCount(options.socket, SESSION_COUNT_TIMEOUT_MS);
|
|
8
|
+
try {
|
|
9
|
+
const legacy = await provenLegacyOwner(paths, options.socket);
|
|
10
|
+
const sessions = counted?.sessions;
|
|
11
|
+
if (sessions !== 0) {
|
|
12
|
+
if (legacy !== undefined) {
|
|
13
|
+
return {
|
|
14
|
+
action: "refuse",
|
|
15
|
+
reason: "legacy_host",
|
|
16
|
+
upgradeable: true,
|
|
17
|
+
detail: busyLegacyHostDetail(legacy.pid, options.socket, sessions),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
action: "refuse",
|
|
22
|
+
reason: "unknown_owner",
|
|
23
|
+
upgradeable: true,
|
|
24
|
+
detail: `${options.socket} has no provable owner and holds ${describeSessions(sessions)}; it is never signalled - let that work finish, then retry`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const result = await startSuccessor({
|
|
28
|
+
options,
|
|
29
|
+
paths,
|
|
30
|
+
host,
|
|
31
|
+
owner: legacy,
|
|
32
|
+
drainGate: async () => (await counted?.recount(SESSION_COUNT_TIMEOUT_MS)) === 0,
|
|
33
|
+
});
|
|
34
|
+
if (result.action === "handoff" && legacy === undefined)
|
|
35
|
+
warnUnsignalledPredecessor(options.socket, host);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
counted?.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The one record of a predecessor nobody could prove, on the caller's stderr: the endpoint's log belongs
|
|
44
|
+
* to the generations writing it. Its pid is not known: no record proves it, and a Unix socket's peer
|
|
45
|
+
* credentials are not readable from this runtime.
|
|
46
|
+
*/
|
|
47
|
+
function warnUnsignalledPredecessor(socket, host) {
|
|
48
|
+
process.stderr.write(`generation handoff: no provable owner for ${socket}; the host it replaced (instance ${host.instanceId ?? "unknown"}, engine ${host.engineVersion ?? "unknown"}) listed 0 sessions and was not signalled; it drains itself on losing the public socket\n`);
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=host-handoff-unregistered.js.map
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { HostLifecyclePolicyInput } from "./host-lifecycle.ts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The longest a handoff holds the ensure lock: its probe of the running host, the two session counts
|
|
4
|
+
* an unregistered host gets (before the swap and the recount after it), then the successor's start.
|
|
5
|
+
*/
|
|
3
6
|
export declare const HANDOFF_LOCK_HOLD_MS: number;
|
|
4
7
|
export interface HandoffHostOptions {
|
|
5
8
|
readonly socket: string;
|
|
@@ -43,8 +46,10 @@ export type HandoffRefusal =
|
|
|
43
46
|
| "handoff_unsupported"
|
|
44
47
|
/** win32: a named pipe can be neither renamed nor drained. */
|
|
45
48
|
| "upgrade_unsupported"
|
|
46
|
-
/**
|
|
49
|
+
/** No record proves which process serves this socket, and that host holds sessions (or will not say). */
|
|
47
50
|
| "unknown_owner"
|
|
51
|
+
/** A host from before layout 2, proven by its flat record, holds sessions; `detail` says how to retire it. */
|
|
52
|
+
| "legacy_host"
|
|
48
53
|
/** The public socket stopped being the one this handoff was decided against. */
|
|
49
54
|
| "socket_replaced"
|
|
50
55
|
/** `<public>.next-<gen>` would exceed the platform's socket path limit. */
|
|
@@ -31,11 +31,15 @@ import { createDaemonDirectories, createHostDaemonPaths } from "./host-daemon-pa
|
|
|
31
31
|
import { provenOwner, readHostRegistration } from "./host-daemon-registration.js";
|
|
32
32
|
import { GENERATION_HANDOFF_CAPABILITY } from "./host-decision.js";
|
|
33
33
|
import { acquireHostEnsureLock } from "./host-ensure-lock.js";
|
|
34
|
+
import { handoffUnregisteredHost, SESSION_COUNT_TIMEOUT_MS } from "./host-handoff-unregistered.js";
|
|
34
35
|
import { probeProtocolInfo } from "./host-probe.js";
|
|
35
36
|
import { SUCCESSOR_START_BUDGET_MS, startSuccessor } from "./host-successor.js";
|
|
36
37
|
const HANDOFF_PROBE_TIMEOUT_MS = 10_000;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The longest a handoff holds the ensure lock: its probe of the running host, the two session counts
|
|
40
|
+
* an unregistered host gets (before the swap and the recount after it), then the successor's start.
|
|
41
|
+
*/
|
|
42
|
+
export const HANDOFF_LOCK_HOLD_MS = HANDOFF_PROBE_TIMEOUT_MS + 2 * SESSION_COUNT_TIMEOUT_MS + SUCCESSOR_START_BUDGET_MS;
|
|
39
43
|
/**
|
|
40
44
|
* How long a handoff waits for the lock. The longest holder is an ensure that probes the running host
|
|
41
45
|
* and then hands it off itself (its upgrade path); 10 s of headroom for a slow runner.
|
|
@@ -74,7 +78,7 @@ export async function handoffHostLocked(options) {
|
|
|
74
78
|
const registered = await readHostRegistration(paths);
|
|
75
79
|
const owner = await provenOwner(registered, options.socket);
|
|
76
80
|
if (!owner)
|
|
77
|
-
return
|
|
81
|
+
return handoffUnregisteredHost(options, paths, host);
|
|
78
82
|
return startSuccessor({ options, paths, host, owner });
|
|
79
83
|
}
|
|
80
84
|
function handoffUnsupported(options) {
|
|
@@ -15,4 +15,7 @@ export declare function provenLegacyOwner(paths: HostDaemonPaths, socket: string
|
|
|
15
15
|
pid: number;
|
|
16
16
|
processStartTime: string;
|
|
17
17
|
} | undefined>;
|
|
18
|
+
/** Why a proven legacy host that holds work is left alone, and the command that retires it. */
|
|
19
|
+
export declare function busyLegacyHostDetail(pid: number, socket: string, sessions: number | undefined): string;
|
|
20
|
+
export declare function describeSessions(sessions: number | undefined): string;
|
|
18
21
|
//# sourceMappingURL=host-legacy.d.ts.map
|
|
@@ -96,11 +96,16 @@ async function judgeLegacyHost(paths, probe, platform = process.platform) {
|
|
|
96
96
|
const sessions = await probeSessionCount(host.socket, LEGACY_PROBE_TIMEOUT_MS);
|
|
97
97
|
if (sessions === 0)
|
|
98
98
|
return { verdict: "idle", host };
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
};
|
|
99
|
+
return { verdict: "held", detail: busyLegacyHostDetail(pid, host.socket, sessions) };
|
|
100
|
+
}
|
|
101
|
+
/** Why a proven legacy host that holds work is left alone, and the command that retires it. */
|
|
102
|
+
export function busyLegacyHostDetail(pid, socket, sessions) {
|
|
103
|
+
return `pid ${pid} (${socket}) holds ${describeSessions(sessions)}; run \`${APP_NAME} host stop --drain --socket ${socket}\` to let it finish that work and exit, then retry`;
|
|
104
|
+
}
|
|
105
|
+
export function describeSessions(sessions) {
|
|
106
|
+
return sessions === undefined
|
|
107
|
+
? "an unknown number of sessions"
|
|
108
|
+
: `${sessions} open session${sessions === 1 ? "" : "s"}`;
|
|
104
109
|
}
|
|
105
110
|
/**
|
|
106
111
|
* SIGUSR1, then wait for the proven process to leave. A host that exits before the signal lands has
|
|
@@ -63,6 +63,7 @@ import { hostCrashCleanupPaths } from "./host-cleanup-paths.js";
|
|
|
63
63
|
import { createHostDaemonPaths, generationPaths, HOST_DAEMON_DIR_ENV } from "./host-daemon-paths.js";
|
|
64
64
|
import { releaseGeneration } from "./host-daemon-registration.js";
|
|
65
65
|
import { HOST_INSTANCE_ID_ENV } from "./host-identity-env.js";
|
|
66
|
+
import { SessionRunActivity } from "./host-run-activity.js";
|
|
66
67
|
import { watchForSupersession } from "./host-supersession.js";
|
|
67
68
|
import { HOST_CLEANUP_PATHS_ENV, HOST_PUBLIC_SOCKET_ENV, HOST_SCRATCH_DIR_ENV, HOST_WATCH_FD_ENV, HOST_WATCH_PPID_ENV, } from "./host-watchdog.js";
|
|
68
69
|
import { attachJsonlLineReader, MAX_RPC_LINE_CHARACTERS } from "./jsonl.js";
|
|
@@ -319,7 +320,7 @@ export async function runHostSupervisor(launch) {
|
|
|
319
320
|
const internalSecretPath = internal.secretPath ?? socketSecretPath(internalSocket);
|
|
320
321
|
const internalSecret = process.platform === "win32" ? await createSocketSecret(internalSecretPath) : undefined;
|
|
321
322
|
const clients = new ClientOccupancy(() => decider.update(currentActivity()));
|
|
322
|
-
const
|
|
323
|
+
const runs = new SessionRunActivity();
|
|
323
324
|
// Declared before anything that can reach `currentActivity()`. A client accepted during startup
|
|
324
325
|
// asks for the activity snapshot, and a `const` read before its initializer runs is a
|
|
325
326
|
// ReferenceError that fails the connection - which is how a successor's first `open_session`
|
|
@@ -461,37 +462,13 @@ export async function runHostSupervisor(launch) {
|
|
|
461
462
|
unhealthySince: observerLink.unhealthySince(),
|
|
462
463
|
now: Date.now(),
|
|
463
464
|
unknownGraceMs: decider.idleExitMs,
|
|
464
|
-
observedBusy:
|
|
465
|
+
observedBusy: runs.busySessions,
|
|
465
466
|
}),
|
|
466
467
|
};
|
|
467
468
|
}
|
|
468
|
-
function countBusySessions() {
|
|
469
|
-
let busy = 0;
|
|
470
|
-
for (const count of busySessions.values())
|
|
471
|
-
if (count > 0)
|
|
472
|
-
busy++;
|
|
473
|
-
return busy;
|
|
474
|
-
}
|
|
475
469
|
function observeHostEvent(line) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
event = JSON.parse(line);
|
|
479
|
-
}
|
|
480
|
-
catch {
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
if (typeof event !== "object" || event === null)
|
|
484
|
-
return;
|
|
485
|
-
const { type, sessionId } = event;
|
|
486
|
-
if (typeof sessionId !== "string")
|
|
487
|
-
return;
|
|
488
|
-
if (type === "agent_start")
|
|
489
|
-
busySessions.set(sessionId, (busySessions.get(sessionId) ?? 0) + 1);
|
|
490
|
-
else if (type === "agent_settled")
|
|
491
|
-
busySessions.set(sessionId, Math.max(0, (busySessions.get(sessionId) ?? 1) - 1));
|
|
492
|
-
else
|
|
493
|
-
return;
|
|
494
|
-
decider.update(currentActivity());
|
|
470
|
+
if (runs.observe(line))
|
|
471
|
+
decider.update(currentActivity());
|
|
495
472
|
}
|
|
496
473
|
async function shutdown(reason, exitCode) {
|
|
497
474
|
// Single-flight: concurrent triggers (listener error, child exit, signals)
|
|
@@ -32,6 +32,18 @@ export declare function probeSocketReachable(socketPath: string, timeoutMs: numb
|
|
|
32
32
|
* does not answer. A stop decision needs the number the host reports, not the one a client remembers.
|
|
33
33
|
*/
|
|
34
34
|
export declare function probeSessionCount(socketPath: string, timeoutMs: number): Promise<number | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* The session count together with the connection that answered it, kept open so the SAME host can be
|
|
37
|
+
* asked again after its public path was handed to somebody else: the recount reaches the process that
|
|
38
|
+
* was counted, never whoever serves the path by then. `undefined` when the host does not answer.
|
|
39
|
+
*/
|
|
40
|
+
export interface HeldSessionCount {
|
|
41
|
+
readonly sessions: number;
|
|
42
|
+
/** Asks the counted host again over the held connection; `undefined` when it no longer answers. */
|
|
43
|
+
recount(timeoutMs: number): Promise<number | undefined>;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
export declare function holdSessionCount(socketPath: string, timeoutMs: number): Promise<HeldSessionCount | undefined>;
|
|
35
47
|
/** Sends one command and returns its `data`, or `undefined` for any failure to get a usable answer. */
|
|
36
48
|
export declare function requestOnSocket(socketPath: string, command: Readonly<Record<string, unknown>>, timeoutMs: number): Promise<unknown>;
|
|
37
49
|
//# sourceMappingURL=host-probe.d.ts.map
|
|
@@ -59,10 +59,58 @@ export async function probeSocketReachable(socketPath, timeoutMs) {
|
|
|
59
59
|
* does not answer. A stop decision needs the number the host reports, not the one a client remembers.
|
|
60
60
|
*/
|
|
61
61
|
export async function probeSessionCount(socketPath, timeoutMs) {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
return sessionCountOf(await requestOnSocket(socketPath, { type: "list_sessions", include_workers: true }, timeoutMs));
|
|
63
|
+
}
|
|
64
|
+
export async function holdSessionCount(socketPath, timeoutMs) {
|
|
65
|
+
const outcome = await connectAndAsk(socketPath, { id: PROBE_REQUEST_ID, type: "list_sessions", include_workers: true }, timeoutMs, true);
|
|
66
|
+
const kept = outcome.kept;
|
|
67
|
+
if (kept === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
const sessions = sessionCountOf(outcome.answer);
|
|
70
|
+
if (sessions === undefined) {
|
|
71
|
+
kept.destroy();
|
|
64
72
|
return undefined;
|
|
65
|
-
|
|
73
|
+
}
|
|
74
|
+
let asked = 0;
|
|
75
|
+
return {
|
|
76
|
+
sessions,
|
|
77
|
+
recount: (recountTimeoutMs) => askAgain(kept, `${PROBE_REQUEST_ID}-recount-${++asked}`, recountTimeoutMs),
|
|
78
|
+
close: () => kept.destroy(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function askAgain(socket, id, timeoutMs) {
|
|
82
|
+
return new Promise((resolveCount) => {
|
|
83
|
+
let buffer = "";
|
|
84
|
+
const finish = (count) => {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
socket.off("data", onData);
|
|
87
|
+
socket.off("close", onClose);
|
|
88
|
+
resolveCount(count);
|
|
89
|
+
};
|
|
90
|
+
const onClose = () => finish(undefined);
|
|
91
|
+
const onData = (chunk) => {
|
|
92
|
+
buffer += chunk.toString("utf8");
|
|
93
|
+
for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) {
|
|
94
|
+
const answer = readAnswer(buffer.slice(0, newline), id);
|
|
95
|
+
buffer = buffer.slice(newline + 1);
|
|
96
|
+
if (answer !== undefined) {
|
|
97
|
+
finish(sessionCountOf(answer));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const timeout = setTimeout(() => finish(undefined), timeoutMs);
|
|
103
|
+
if (socket.destroyed) {
|
|
104
|
+
finish(undefined);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
socket.on("data", onData);
|
|
108
|
+
socket.once("close", onClose);
|
|
109
|
+
socket.write(`${JSON.stringify({ id, type: "list_sessions", include_workers: true })}\n`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function sessionCountOf(reply) {
|
|
113
|
+
return isRecord(reply) && Array.isArray(reply.sessions) ? reply.sessions.length : undefined;
|
|
66
114
|
}
|
|
67
115
|
/** Sends one command and returns its `data`, or `undefined` for any failure to get a usable answer. */
|
|
68
116
|
export async function requestOnSocket(socketPath, command, timeoutMs) {
|
|
@@ -125,7 +173,7 @@ async function connectAndAsk(socketPath, request, timeoutMs, keep = false) {
|
|
|
125
173
|
sendSocketHandshake(socket, secret);
|
|
126
174
|
});
|
|
127
175
|
}
|
|
128
|
-
function readAnswer(text) {
|
|
176
|
+
function readAnswer(text, id = PROBE_REQUEST_ID) {
|
|
129
177
|
let parsed;
|
|
130
178
|
try {
|
|
131
179
|
parsed = JSON.parse(text);
|
|
@@ -133,7 +181,7 @@ function readAnswer(text) {
|
|
|
133
181
|
catch {
|
|
134
182
|
return undefined;
|
|
135
183
|
}
|
|
136
|
-
if (!isRecord(parsed) || parsed.id !==
|
|
184
|
+
if (!isRecord(parsed) || parsed.id !== id || parsed.success !== true)
|
|
137
185
|
return undefined;
|
|
138
186
|
return parsed.data ?? {};
|
|
139
187
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions have an agent run in progress, from the host child's events. A run emits
|
|
3
|
+
* `agent_start` on every loop iteration (a provider retry, a post-compaction continue, a follow-up)
|
|
4
|
+
* but `agent_settled` once, so this tracks runs, not starts: a repeated start for a running session
|
|
5
|
+
* changes nothing. A session that closes is dropped even if its settle never arrives (#2713).
|
|
6
|
+
*/
|
|
7
|
+
export declare class SessionRunActivity {
|
|
8
|
+
#private;
|
|
9
|
+
/** Applies one host event line; true when it is a run or session event the idle decision must re-read. */
|
|
10
|
+
observe(line: string): boolean;
|
|
11
|
+
get busySessions(): number;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=host-run-activity.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions have an agent run in progress, from the host child's events. A run emits
|
|
3
|
+
* `agent_start` on every loop iteration (a provider retry, a post-compaction continue, a follow-up)
|
|
4
|
+
* but `agent_settled` once, so this tracks runs, not starts: a repeated start for a running session
|
|
5
|
+
* changes nothing. A session that closes is dropped even if its settle never arrives (#2713).
|
|
6
|
+
*/
|
|
7
|
+
export class SessionRunActivity {
|
|
8
|
+
#running = new Set();
|
|
9
|
+
/** Applies one host event line; true when it is a run or session event the idle decision must re-read. */
|
|
10
|
+
observe(line) {
|
|
11
|
+
const event = parseHostEvent(line);
|
|
12
|
+
if (event === undefined)
|
|
13
|
+
return false;
|
|
14
|
+
if (event.type === "agent_start")
|
|
15
|
+
this.#running.add(event.sessionId);
|
|
16
|
+
else if (event.type === "agent_settled" || event.type === "session_closed")
|
|
17
|
+
this.#running.delete(event.sessionId);
|
|
18
|
+
else
|
|
19
|
+
return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
get busySessions() {
|
|
23
|
+
return this.#running.size;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function parseHostEvent(line) {
|
|
27
|
+
let event;
|
|
28
|
+
try {
|
|
29
|
+
event = JSON.parse(line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (typeof event !== "object" || event === null)
|
|
35
|
+
return undefined;
|
|
36
|
+
const { type, sessionId } = event;
|
|
37
|
+
return typeof sessionId === "string" ? { type, sessionId } : undefined;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=host-run-activity.js.map
|
|
@@ -164,7 +164,7 @@ async function handoffOutcome(target, spec) {
|
|
|
164
164
|
return refusal("refuse", before, {
|
|
165
165
|
reason: upgradeRefusal(result.reason),
|
|
166
166
|
socket: target.socket,
|
|
167
|
-
detail: result.reason,
|
|
167
|
+
detail: result.detail ?? result.reason,
|
|
168
168
|
upgradeable: result.upgradeable,
|
|
169
169
|
});
|
|
170
170
|
}
|
|
@@ -7,10 +7,11 @@ export declare function startSuccessor(context: {
|
|
|
7
7
|
options: HandoffHostOptions;
|
|
8
8
|
paths: HostDaemonPaths;
|
|
9
9
|
host: HostProtocolInfo;
|
|
10
|
+
/** The predecessor's proven identity; `undefined` when nothing proves it, and then it is never signalled. */
|
|
10
11
|
owner: {
|
|
11
|
-
pid: number;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
readonly pid: number;
|
|
13
|
+
} | undefined;
|
|
14
|
+
/** Asked once the successor owns the socket: the drain request is sent only when it answers true. */
|
|
15
|
+
drainGate?: () => Promise<boolean>;
|
|
15
16
|
}): Promise<HandoffResult>;
|
|
16
17
|
//# sourceMappingURL=host-successor.d.ts.map
|
|
@@ -21,7 +21,7 @@ import { randomUUID } from "node:crypto";
|
|
|
21
21
|
import { open, rm, writeFile } from "node:fs/promises";
|
|
22
22
|
import { waitForStartTime } from "../app-server/daemon/process.js";
|
|
23
23
|
import { generationPaths, HOST_STATE_FILE_MODE } from "./host-daemon-paths.js";
|
|
24
|
-
import { releaseGeneration, writeGenerationRecord, writeHostRegistration } from "./host-daemon-registration.js";
|
|
24
|
+
import { releaseGeneration, writeGenerationRecord, writeHostRegistration, } from "./host-daemon-registration.js";
|
|
25
25
|
import { readFileOrUndefined, readHostSettings, writeHostSettings } from "./host-daemon-state.js";
|
|
26
26
|
import { defaultHostLaunch, PINNED_HOST_CLIENT_CAPABILITIES } from "./host-launch.js";
|
|
27
27
|
import { DEFAULT_HOST_IDLE_EXIT_MS } from "./host-lifecycle.js";
|
|
@@ -37,7 +37,7 @@ const SUCCESSOR_EXIT_WAIT_MS = 5_000;
|
|
|
37
37
|
/** The longest `startSuccessor` runs: its start-time read, the readiness window, one last probe and a refusal's exit wait. */
|
|
38
38
|
export const SUCCESSOR_START_BUDGET_MS = 10_000 + DEFAULT_HANDOFF_READINESS_MS + 2_000 + SUCCESSOR_EXIT_WAIT_MS;
|
|
39
39
|
export async function startSuccessor(context) {
|
|
40
|
-
const { options, paths, host, owner } = context;
|
|
40
|
+
const { options, paths, host, owner, drainGate } = context;
|
|
41
41
|
const generation = (host.generation ?? 0) + 1;
|
|
42
42
|
// The successor's identity, chosen here so its generation directory holds its settings before it
|
|
43
43
|
// boots and the pointer can name it the instant it answers on the public socket.
|
|
@@ -119,13 +119,14 @@ export async function startSuccessor(context) {
|
|
|
119
119
|
// The pointer moves to the successor only now: until the rename landed, the generation the
|
|
120
120
|
// clients reach is still the predecessor, and the pointer has to name whoever owns the socket.
|
|
121
121
|
await options._test?.beforeRegistration?.();
|
|
122
|
-
await writeHostRegistration(paths, registration);
|
|
122
|
+
await writeHostRegistration(paths, { ...registration, ...successorBuild(answer) });
|
|
123
123
|
child.unref();
|
|
124
124
|
// The successor owns the socket now: the predecessor may drain. SIGUSR1 is sent only here,
|
|
125
125
|
// to a pid the record proved and a host that advertised it can survive the signal. A
|
|
126
126
|
// predecessor that exited on its own in the meantime is already drained, and the handoff it
|
|
127
127
|
// was being asked to make room for has already happened.
|
|
128
|
-
|
|
128
|
+
if (owner !== undefined && (drainGate === undefined || (await drainGate())))
|
|
129
|
+
signalGeneration(owner.pid, "SIGUSR1");
|
|
129
130
|
return {
|
|
130
131
|
action: "handoff",
|
|
131
132
|
pid: child.pid,
|
|
@@ -185,6 +186,12 @@ async function exitedWithin(exited, ms) {
|
|
|
185
186
|
clearTimeout(timer);
|
|
186
187
|
}
|
|
187
188
|
}
|
|
189
|
+
/** The successor's build as the successor itself reported it on the socket; never this process's build. */
|
|
190
|
+
function successorBuild(answer) {
|
|
191
|
+
if (answer.engineVersion === undefined || answer.engineOrdinal === undefined)
|
|
192
|
+
return {};
|
|
193
|
+
return { build: { text: answer.engineVersion, ordinal: answer.engineOrdinal } };
|
|
194
|
+
}
|
|
188
195
|
/**
|
|
189
196
|
* The successor's own answer on the PUBLIC socket is the only proof the rename landed: it
|
|
190
197
|
* reports a different `instanceId` than the generation being replaced. An exit instead means
|
package/docs/rpc.md
CHANGED
|
@@ -288,8 +288,9 @@ Two guards decide whether a handoff is attempted at all, and both fail closed:
|
|
|
288
288
|
handler for it, so a host from before the drain existed is never signalled - `handoffHost` answers
|
|
289
289
|
`{ action: "refuse", reason: "handoff_unsupported" }` and `decideHostAction` reports `upgradeable: false`.
|
|
290
290
|
- The registration must prove which process serves the socket (pid + start time, and the record's `socket`
|
|
291
|
-
must be this endpoint)
|
|
292
|
-
stranger (I1)
|
|
291
|
+
must be this endpoint) before that process is signalled. Without such a registration a handoff never
|
|
292
|
+
signals a stranger (I1): it refuses while that host holds a session, and replaces an idle one without
|
|
293
|
+
a signal unless a flat pre-layout-2 record proves it (see "Daemon state directory (layout 2)").
|
|
293
294
|
|
|
294
295
|
On win32 a named pipe can be neither renamed nor drained: `handoffHost` refuses with `upgrade_unsupported`
|
|
295
296
|
and `decideHostAction` never yields `handoff` there. Upgrades apply after `stopHost({ drain: true })` or an
|
|
@@ -430,6 +431,13 @@ pid and start time must match the live process, so a recycled pid proves nothing
|
|
|
430
431
|
its endpoint answers with `generation_handoff` and `list_sessions` (workers included) lists no session.
|
|
431
432
|
Otherwise it refuses `legacy_host`, and the CLI refusal carries `detail`: the pid, its endpoint, how many
|
|
432
433
|
sessions it holds and the `host stop --drain --socket <endpoint>` that retires it.
|
|
434
|
+
- A handoff (`host handoff`, and an ensure's upgrade) on an endpoint no layout-2 record proves counts the
|
|
435
|
+
running host's sessions over a connection it keeps open. With any session, or no answer, it refuses:
|
|
436
|
+
`legacy_host` with the same `detail` when a flat record proves the process, `unknown_owner` with the
|
|
437
|
+
session count otherwise. With none, the successor takes the socket; a proven legacy process is sent the
|
|
438
|
+
drain only if a recount over that held connection still lists no session, and a host no record proves
|
|
439
|
+
is never signalled - it drains itself once another generation owns the public entry, and the handoff
|
|
440
|
+
writes one stderr warning naming the socket, that host's instance and its engine.
|
|
433
441
|
|
|
434
442
|
`ensureHost` fails with a typed `HostDaemonStateError` naming the directory it could not create or write,
|
|
435
443
|
and starts no host in that case.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.8",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"piConfig": {
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@earendil-works/chord": "0.99.1",
|
|
60
|
-
"@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.
|
|
61
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
62
|
-
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.
|
|
60
|
+
"@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.8",
|
|
61
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.8",
|
|
62
|
+
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.8",
|
|
63
63
|
"@silvia-odwyer/photon-node": "0.3.4",
|
|
64
64
|
"chalk": "6.0.0",
|
|
65
65
|
"cross-spawn": "7.0.6",
|
|
@@ -76,10 +76,10 @@
|
|
|
76
76
|
"typebox": "1.3.34",
|
|
77
77
|
"undici": "8.10.2",
|
|
78
78
|
"yaml": "2.9.1",
|
|
79
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
79
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.289",
|
|
80
80
|
"@anthropic-ai/sdk": "0.127.0",
|
|
81
|
-
"@code-yeongyu/senpi-codemode": "2026.10.
|
|
82
|
-
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.
|
|
81
|
+
"@code-yeongyu/senpi-codemode": "2026.10.8",
|
|
82
|
+
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.8",
|
|
83
83
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
84
84
|
"@mozilla/readability": "0.6.0",
|
|
85
85
|
"linkedom": "0.18.13",
|