@mnemom/mnemom 0.17.33 → 0.17.34-next.2
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/dist/base-url-proxy.mjs +18 -4
- package/dist/commands/agent-config.js +5 -1
- package/dist/commands/agent-doctor.d.ts +9 -0
- package/dist/commands/agent-doctor.js +4 -0
- package/dist/commands/agent-session.d.ts +1 -2
- package/dist/commands/agent-session.js +1 -2
- package/dist/commands/agent-setup.d.ts +6 -0
- package/dist/commands/agent-setup.js +12 -1
- package/dist/commands/agent-share-render.d.ts +1 -1
- package/dist/commands/agent-share-render.js +1 -1
- package/dist/commands/agent.d.ts +26 -0
- package/dist/commands/agent.js +228 -53
- package/dist/commands/durable-doctor.d.ts +50 -0
- package/dist/commands/durable-doctor.js +186 -0
- package/dist/commands/durable-lock.d.ts +254 -0
- package/dist/commands/durable-lock.js +595 -0
- package/dist/commands/durable-sessions.d.ts +87 -0
- package/dist/commands/durable-sessions.js +303 -0
- package/dist/commands/durable-verbs.d.ts +116 -0
- package/dist/commands/durable-verbs.js +393 -0
- package/dist/commands/protection.js +1 -1
- package/dist/commands/scan.d.ts +97 -0
- package/dist/commands/scan.js +238 -0
- package/dist/durable/flock.d.ts +26 -0
- package/dist/durable/flock.js +80 -0
- package/dist/durable/launch-spec.d.ts +7 -1
- package/dist/durable/launch-spec.js +10 -3
- package/dist/durable/seat-proxy-entry.d.ts +11 -0
- package/dist/durable/seat-proxy-entry.js +25 -0
- package/dist/durable/seat-proxy.d.ts +107 -0
- package/dist/durable/seat-proxy.js +599 -0
- package/dist/durable/seat-socket.d.ts +46 -0
- package/dist/durable/seat-socket.js +179 -0
- package/dist/durable/seat-spec-validator.d.ts +28 -0
- package/dist/durable/seat-spec-validator.js +59 -0
- package/dist/durable/test-mode.mjs +81 -0
- package/dist/durable/turn-chain.d.ts +55 -0
- package/dist/durable/turn-chain.js +178 -0
- package/dist/host/auto-continue.d.ts +67 -0
- package/dist/host/auto-continue.js +118 -0
- package/dist/host/bootid.d.ts +13 -0
- package/dist/host/bootid.js +41 -0
- package/dist/host/client.d.ts +123 -0
- package/dist/host/client.js +171 -0
- package/dist/host/commands.d.ts +2 -0
- package/dist/host/commands.js +122 -0
- package/dist/host/control.d.ts +33 -0
- package/dist/host/control.js +144 -0
- package/dist/host/daemon.d.ts +217 -0
- package/dist/host/daemon.js +606 -0
- package/dist/host/gate.d.ts +46 -0
- package/dist/host/gate.js +82 -0
- package/dist/host/grant.d.ts +24 -0
- package/dist/host/grant.js +24 -0
- package/dist/host/instance.d.ts +35 -0
- package/dist/host/instance.js +62 -0
- package/dist/host/integrity.d.ts +35 -0
- package/dist/host/integrity.js +115 -0
- package/dist/host/launchd.d.ts +16 -0
- package/dist/host/launchd.js +45 -0
- package/dist/host/lock.d.ts +20 -0
- package/dist/host/lock.js +63 -0
- package/dist/host/owner.d.ts +55 -0
- package/dist/host/owner.js +121 -0
- package/dist/host/plist.d.ts +16 -0
- package/dist/host/plist.js +85 -0
- package/dist/host/procs.d.ts +27 -0
- package/dist/host/procs.js +105 -0
- package/dist/host/run.d.ts +66 -0
- package/dist/host/run.js +314 -0
- package/dist/host/runtime.d.ts +8 -0
- package/dist/host/runtime.js +22 -0
- package/dist/host/s1-names.d.ts +22 -0
- package/dist/host/s1-names.js +21 -0
- package/dist/host/seat-credential.d.ts +27 -0
- package/dist/host/seat-credential.js +41 -0
- package/dist/host/seat-launch-main.d.ts +1 -0
- package/dist/host/seat-launch-main.js +59 -0
- package/dist/host/seat-launch.d.ts +116 -0
- package/dist/host/seat-launch.js +302 -0
- package/dist/host/seat.d.ts +48 -0
- package/dist/host/seat.js +149 -0
- package/dist/host/state.d.ts +33 -0
- package/dist/host/state.js +117 -0
- package/dist/host/status.d.ts +101 -0
- package/dist/host/status.js +307 -0
- package/dist/host/tmux.d.ts +56 -0
- package/dist/host/tmux.js +182 -0
- package/dist/host/transcript.d.ts +30 -0
- package/dist/host/transcript.js +130 -0
- package/dist/host/verbs.d.ts +50 -0
- package/dist/host/verbs.js +271 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.js +232 -35
- package/dist/lib/agent.d.ts +8 -26
- package/dist/lib/agent.js +11 -26
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +8 -0
- package/dist/lib/claude-plugin.d.ts +11 -2
- package/dist/lib/claude-plugin.js +10 -1
- package/dist/lib/durable/api.d.ts +136 -0
- package/dist/lib/durable/api.js +220 -0
- package/dist/lib/durable/codes.d.ts +8 -0
- package/dist/lib/durable/codes.js +2 -0
- package/dist/lib/durable/first-turn.d.ts +50 -0
- package/dist/lib/durable/first-turn.js +102 -0
- package/dist/lib/durable/gate.d.ts +48 -0
- package/dist/lib/durable/gate.js +122 -0
- package/dist/lib/durable/handover.d.ts +101 -0
- package/dist/lib/durable/handover.js +175 -0
- package/dist/lib/durable/launch.d.ts +182 -0
- package/dist/lib/durable/launch.js +443 -0
- package/dist/lib/durable/launching.d.ts +15 -0
- package/dist/lib/durable/launching.js +70 -0
- package/dist/lib/durable/live.d.ts +194 -0
- package/dist/lib/durable/live.js +635 -0
- package/dist/lib/durable/refusal.d.ts +57 -0
- package/dist/lib/durable/refusal.js +78 -0
- package/dist/lib/durable/view.d.ts +83 -0
- package/dist/lib/durable/view.js +244 -0
- package/dist/lib/experimental.d.ts +0 -9
- package/dist/lib/experimental.js +18 -17
- package/dist/lib/launcher-config.d.ts +7 -0
- package/dist/lib/launcher-config.js +21 -0
- package/dist/lib/scan/caps.d.ts +35 -0
- package/dist/lib/scan/caps.js +67 -0
- package/dist/lib/scan/credential.d.ts +37 -0
- package/dist/lib/scan/credential.js +104 -0
- package/dist/lib/scan/failure.d.ts +10 -0
- package/dist/lib/scan/failure.js +43 -0
- package/dist/lib/scan/pr-source.d.ts +57 -0
- package/dist/lib/scan/pr-source.js +130 -0
- package/dist/lib/scan/record.d.ts +49 -0
- package/dist/lib/scan/record.js +85 -0
- package/dist/lib/scan/report-email.d.ts +92 -0
- package/dist/lib/scan/report-email.js +144 -0
- package/dist/lib/scan/report.d.ts +13 -0
- package/dist/lib/scan/report.js +30 -0
- package/dist/lib/scan/scan.d.ts +64 -0
- package/dist/lib/scan/scan.js +182 -0
- package/dist/lib/scan/share.d.ts +38 -0
- package/dist/lib/scan/share.js +47 -0
- package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
- package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
- package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
- package/dist/lib/scan/spend/anthropic-usage.js +244 -0
- package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
- package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
- package/dist/lib/scan/spend/prices.d.ts +33 -0
- package/dist/lib/scan/spend/prices.js +57 -0
- package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
- package/dist/lib/scan/spend/spend-estimate.js +70 -0
- package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
- package/dist/lib/scan/spend/spend-tiers.js +42 -0
- package/dist/lib/scan/spend/token-rate.d.ts +30 -0
- package/dist/lib/scan/spend/token-rate.js +54 -0
- package/dist/lib/session-key.d.ts +41 -0
- package/dist/lib/session-key.js +124 -0
- package/dist/lib/subscription-login.d.ts +15 -0
- package/dist/lib/subscription-login.js +28 -1
- package/dist/rc-key-401.mjs +75 -0
- package/dist/rc-proxy.mjs +221 -25
- package/dist/vendor/durable-contract/index.js +240 -18
- package/dist/vendor/shared/durable-contract/admission.d.ts +240 -0
- package/dist/vendor/shared/durable-contract/admission.js +533 -0
- package/dist/vendor/shared/durable-contract/arm.d.ts +51 -0
- package/dist/vendor/shared/durable-contract/arm.js +133 -0
- package/dist/vendor/shared/durable-contract/canonical.d.ts +29 -0
- package/dist/vendor/shared/durable-contract/canonical.js +98 -0
- package/dist/vendor/shared/durable-contract/cell-id.d.ts +22 -0
- package/dist/vendor/shared/durable-contract/cell-id.js +38 -0
- package/dist/vendor/shared/durable-contract/cli-output.d.ts +342 -0
- package/dist/vendor/shared/durable-contract/cli-output.js +201 -0
- package/dist/vendor/shared/durable-contract/cli-surface.d.ts +119 -0
- package/dist/vendor/shared/durable-contract/cli-surface.js +270 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +537 -0
- package/dist/vendor/shared/durable-contract/codes.js +475 -0
- package/dist/vendor/shared/durable-contract/constants.d.ts +153 -0
- package/dist/vendor/shared/durable-contract/constants.js +179 -0
- package/dist/vendor/shared/durable-contract/doors.d.ts +119 -0
- package/dist/vendor/shared/durable-contract/doors.js +249 -0
- package/dist/vendor/shared/durable-contract/envelope.d.ts +103 -0
- package/dist/vendor/shared/durable-contract/envelope.js +317 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +147 -0
- package/dist/vendor/shared/durable-contract/events.js +630 -0
- package/dist/vendor/shared/durable-contract/executor-state.d.ts +41 -0
- package/dist/vendor/shared/durable-contract/executor-state.js +32 -0
- package/dist/vendor/shared/durable-contract/fixlines.d.ts +318 -0
- package/dist/vendor/shared/durable-contract/fixlines.js +483 -0
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +787 -0
- package/dist/vendor/shared/durable-contract/freeze-record.js +361 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +36 -0
- package/dist/vendor/shared/durable-contract/index.js +37 -0
- package/dist/vendor/shared/durable-contract/journal-approvals.d.ts +29 -0
- package/dist/vendor/shared/durable-contract/journal-approvals.js +60 -0
- package/dist/vendor/shared/durable-contract/keys.d.ts +24 -0
- package/dist/vendor/shared/durable-contract/keys.js +66 -0
- package/dist/vendor/shared/durable-contract/lock.d.ts +128 -0
- package/dist/vendor/shared/durable-contract/lock.js +151 -0
- package/dist/vendor/shared/durable-contract/names.d.ts +25 -0
- package/dist/vendor/shared/durable-contract/names.js +78 -0
- package/dist/vendor/shared/durable-contract/owner-verbs.d.ts +175 -0
- package/dist/vendor/shared/durable-contract/owner-verbs.js +403 -0
- package/dist/vendor/shared/durable-contract/paths.d.ts +23 -0
- package/dist/vendor/shared/durable-contract/paths.js +35 -0
- package/dist/vendor/shared/durable-contract/pure-fn.d.ts +48 -0
- package/dist/vendor/shared/durable-contract/pure-fn.js +89 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +205 -0
- package/dist/vendor/shared/durable-contract/routes.js +315 -0
- package/dist/vendor/shared/durable-contract/schema.d.ts +126 -0
- package/dist/vendor/shared/durable-contract/schema.js +441 -0
- package/dist/vendor/shared/durable-contract/seal.d.ts +54 -0
- package/dist/vendor/shared/durable-contract/seal.js +99 -0
- package/dist/vendor/shared/durable-contract/secret-scope.d.ts +28 -0
- package/dist/vendor/shared/durable-contract/secret-scope.js +76 -0
- package/dist/vendor/shared/durable-contract/session-id.d.ts +21 -0
- package/dist/vendor/shared/durable-contract/session-id.js +58 -0
- package/dist/vendor/shared/durable-contract/spec.d.ts +162 -0
- package/dist/vendor/shared/durable-contract/spec.js +286 -0
- package/dist/vendor/shared/durable-contract/states.d.ts +96 -0
- package/dist/vendor/shared/durable-contract/states.js +218 -0
- package/dist/vendor/shared/durable-contract/telemetry.d.ts +94 -0
- package/dist/vendor/shared/durable-contract/telemetry.js +180 -0
- package/dist/vendor/shared/durable-contract/test-mode.d.ts +62 -0
- package/dist/vendor/shared/durable-contract/test-mode.js +140 -0
- package/dist/vendor/shared/durable-contract/turn.d.ts +57 -0
- package/dist/vendor/shared/durable-contract/turn.js +81 -0
- package/dist/vendor/shared/durable-contract/vectors.d.ts +191 -0
- package/dist/vendor/shared/durable-contract/vectors.js +208 -0
- package/package.json +4 -3
- package/plugins/integrity-manifest.json +35 -0
- package/plugins/mnemom/hooks/hooks.json +15 -1
- package/plugins/mnemom/hooks/register.tsx +60 -2
- package/plugins/mnemom/hooks/seat-hello.mjs +116 -0
- package/dist/commands/agent-implicit-notice.d.ts +0 -31
- package/dist/commands/agent-implicit-notice.js +0 -73
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host daemon core (BACKBONE C2; C12 and C15). One instance per state dir (lock.ts, taken by
|
|
3
|
+
* the caller before this class exists). Everything with a side effect goes through `deps`, so
|
|
4
|
+
* every branch is unit-tested without tmux, launchd or a network.
|
|
5
|
+
*
|
|
6
|
+
* Cycle (every tick, TICK_MS):
|
|
7
|
+
* gate (config.json only, R39) -> idle while off
|
|
8
|
+
* first enabled cycle: transcript guards for listed sessions, then adopt-or-kill (R11, R38)
|
|
9
|
+
* seat liveness: report exits (R19, R33), kill hello-deadline misses (R17)
|
|
10
|
+
* heartbeat every lease_seconds/3 for hello'd seats (R18), kill on fence (R20)
|
|
11
|
+
* every EXECUTOR_POLL_S: poll, then claim + spawn up to RESPAWN_CONCURRENCY in flight (R14, R15)
|
|
12
|
+
*
|
|
13
|
+
* The daemon never sends hello itself (R32): the seat's plugin hook does, and the seat proxy relays
|
|
14
|
+
* it to `relayHello` over the control socket; the executor key stays here.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { EXECUTOR_POLL_S, HEARTBEAT_DIVISOR, RESPAWN_CONCURRENCY, CLAIM_BACKOFF_BASE_S, CLAIM_BACKOFF_MAX_S, CLAIM_BACKOFF_JITTER, LEASE_SECONDS_DEFAULT, } from "../vendor/shared/durable-contract/constants.js";
|
|
18
|
+
import { TERMINAL_STATES } from "../vendor/shared/durable-contract/states.js";
|
|
19
|
+
import { IDLE_LOG } from "./gate.js";
|
|
20
|
+
import { classifyClaim, errorCode, } from "./client.js";
|
|
21
|
+
import { seatTmuxSession } from "./tmux.js";
|
|
22
|
+
import { CODE, SEAT_UNKNOWN, SPAWNED_KIND } from "./s1-names.js";
|
|
23
|
+
export const TICK_MS = 1000;
|
|
24
|
+
export const ADOPT_RETRY_MS = 2000;
|
|
25
|
+
export { SPAWNED_KIND } from "./s1-names.js";
|
|
26
|
+
export const spawnIdempotencyKey = (sessionId, epoch, spawnId) => `spawn:${sessionId}:${epoch}:${spawnId}`;
|
|
27
|
+
/** S1 claim backoff: 2 s doubling to 60 s, scaled by 1 + jitter in [0, CLAIM_BACKOFF_JITTER) (R31). */
|
|
28
|
+
export function backoffMs(count, random) {
|
|
29
|
+
const base = Math.min(CLAIM_BACKOFF_MAX_S, CLAIM_BACKOFF_BASE_S * 2 ** Math.max(0, count - 1));
|
|
30
|
+
return Math.round(base * (1 + random() * CLAIM_BACKOFF_JITTER) * 1000);
|
|
31
|
+
}
|
|
32
|
+
/** Heartbeat period from the server's lease (R18): lease_seconds / HEARTBEAT_DIVISOR. */
|
|
33
|
+
export const heartbeatPeriodMs = (leaseSeconds) => Math.max(1, (leaseSeconds * 1000) / HEARTBEAT_DIVISOR);
|
|
34
|
+
const ADOPT_HELLO_STATES = new Set(["running", "parked"]);
|
|
35
|
+
const TERMINAL = new Set(TERMINAL_STATES);
|
|
36
|
+
/** States whose sessions the daemon claims (effective parked, detached; resuming is reclaimed after expiry). */
|
|
37
|
+
const CLAIMABLE = new Set(["parked", "detached", "resuming", "running"]);
|
|
38
|
+
/**
|
|
39
|
+
* The lease length an adopted seat heartbeats on until a heartbeat answer re-derives it (R18):
|
|
40
|
+
* the time left on the current lease (never longer than the S1 default, so a short lease is never
|
|
41
|
+
* out-waited), else the S1 default. The poll carries no lease_seconds (ruling 33.28.2).
|
|
42
|
+
*/
|
|
43
|
+
export function adoptedLeaseSeconds(item, now) {
|
|
44
|
+
const exp = item.lease_expires_at ? Date.parse(item.lease_expires_at) : NaN;
|
|
45
|
+
if (Number.isFinite(exp) && exp > now)
|
|
46
|
+
return Math.max(1, Math.min(LEASE_SECONDS_DEFAULT, Math.ceil((exp - now) / 1000)));
|
|
47
|
+
return LEASE_SECONDS_DEFAULT;
|
|
48
|
+
}
|
|
49
|
+
/** The state says the current epoch's hello was accepted (running, parked, detached(lease_expired)). */
|
|
50
|
+
const helloLanded = (item) => ADOPT_HELLO_STATES.has(item.state ?? "") ||
|
|
51
|
+
(item.state === "detached" && item.state_reason === "lease_expired");
|
|
52
|
+
/**
|
|
53
|
+
* C12-R11: adopt a tagged pane, or say why it is killed. `seen` holds sessions already adopted.
|
|
54
|
+
* The poll carries no spawn id or hello (ruling 33.28.2): the pane's tags name its epoch and spawn,
|
|
55
|
+
* held_here says the server agrees the lease is ours, and the state says whether the epoch's hello
|
|
56
|
+
* landed (hello moves resuming to running). The server stays the arbiter: the heartbeat revives a
|
|
57
|
+
* parked or lease-expired session only for the hello'd spawn, and fences anything else.
|
|
58
|
+
*/
|
|
59
|
+
export function adoptVerdict(pane, item, now, panAlive, seenSessions) {
|
|
60
|
+
if (!item)
|
|
61
|
+
return { adopt: false, why: "unknown_session" };
|
|
62
|
+
if (pane.dead || !panAlive)
|
|
63
|
+
return { adopt: false, why: "dead_pane" };
|
|
64
|
+
if (seenSessions.has(item.id))
|
|
65
|
+
return { adopt: false, why: "duplicate" };
|
|
66
|
+
if (item.lease_epoch === null || pane.epoch !== item.lease_epoch)
|
|
67
|
+
return { adopt: false, why: "older_epoch" };
|
|
68
|
+
if (item.held_here !== true)
|
|
69
|
+
return { adopt: false, why: "not_held" };
|
|
70
|
+
const st = item.state ?? "";
|
|
71
|
+
if (TERMINAL.has(st))
|
|
72
|
+
return { adopt: false, why: "terminal" };
|
|
73
|
+
if (helloLanded(item))
|
|
74
|
+
return { adopt: true, hello: true };
|
|
75
|
+
const exp = item.lease_expires_at ? Date.parse(item.lease_expires_at) : NaN;
|
|
76
|
+
if (st === "resuming" && Number.isFinite(exp) && exp > now)
|
|
77
|
+
return { adopt: true, hello: false };
|
|
78
|
+
if (st === "resuming")
|
|
79
|
+
return { adopt: false, why: "hello_deadline_passed" };
|
|
80
|
+
return { adopt: false, why: `state_${st}${item.state_reason ? `_${item.state_reason}` : ""}` };
|
|
81
|
+
}
|
|
82
|
+
/** How long a pane may outlive its claude before the exit is reported without the pane's status. */
|
|
83
|
+
export const CLAUDE_GONE_GRACE_MS = 3000;
|
|
84
|
+
/**
|
|
85
|
+
* R19: a seat is alive while its claude pid is (or, before claude has started, while its pane
|
|
86
|
+
* process is). A dead pane reports its own exit status; a claude that is gone while the pane
|
|
87
|
+
* lingers is reported after CLAUDE_GONE_GRACE_MS with 137.
|
|
88
|
+
*/
|
|
89
|
+
export function seatLiveness(seat, pane, now, claudePid, alive) {
|
|
90
|
+
if (!pane || pane.panePid === null)
|
|
91
|
+
return { kind: "dead", status: 137 };
|
|
92
|
+
if (pane.dead)
|
|
93
|
+
return { kind: "dead", status: exitStatusOf(pane) };
|
|
94
|
+
const c = claudePid(pane.panePid);
|
|
95
|
+
if (c !== null && alive(c)) {
|
|
96
|
+
seat.claudePid = c;
|
|
97
|
+
seat.claudeGoneAt = null;
|
|
98
|
+
return { kind: "alive" };
|
|
99
|
+
}
|
|
100
|
+
if (seat.claudePid === null)
|
|
101
|
+
return alive(pane.panePid) ? { kind: "alive" } : { kind: "dead", status: 137 };
|
|
102
|
+
if (seat.claudeGoneAt === null)
|
|
103
|
+
seat.claudeGoneAt = now;
|
|
104
|
+
return now - seat.claudeGoneAt >= CLAUDE_GONE_GRACE_MS
|
|
105
|
+
? { kind: "dead", status: 137 }
|
|
106
|
+
: { kind: "alive" };
|
|
107
|
+
}
|
|
108
|
+
/** Exit status as the seat-exit route takes it (0..255); a missing status is a failure. */
|
|
109
|
+
export const exitStatusOf = (pane) => pane && pane.deadStatus !== null ? pane.deadStatus : 137;
|
|
110
|
+
export class HostDaemon {
|
|
111
|
+
d;
|
|
112
|
+
seats = new Map();
|
|
113
|
+
unavailable = new Map();
|
|
114
|
+
lastPoll = -Infinity;
|
|
115
|
+
adoptDone = false;
|
|
116
|
+
idleLogged = false;
|
|
117
|
+
items = new Map();
|
|
118
|
+
integrityState = { ok: true };
|
|
119
|
+
/** The cause claims were last held for, so the log names each change once (mnemom-test #981). */
|
|
120
|
+
heldFor = null;
|
|
121
|
+
firstBatchDone = false;
|
|
122
|
+
stopped = false;
|
|
123
|
+
/** The last poll said the grant lapsed (ruling 33.28(4)); logged once per transition. */
|
|
124
|
+
grantExpired = false;
|
|
125
|
+
constructor(d) {
|
|
126
|
+
this.d = d;
|
|
127
|
+
}
|
|
128
|
+
stop() {
|
|
129
|
+
this.stopped = true;
|
|
130
|
+
}
|
|
131
|
+
/** One pass of the loop. Returns what it did (for tests and logs). */
|
|
132
|
+
async tick() {
|
|
133
|
+
const did = [];
|
|
134
|
+
if (!this.d.gateOn()) {
|
|
135
|
+
// Once per transition to off: one log line and one runtime-file write (no HOME churn while idle).
|
|
136
|
+
if (!this.idleLogged) {
|
|
137
|
+
this.d.log(IDLE_LOG);
|
|
138
|
+
this.idleLogged = true;
|
|
139
|
+
this.publish("idle");
|
|
140
|
+
}
|
|
141
|
+
return ["idle"];
|
|
142
|
+
}
|
|
143
|
+
this.idleLogged = false;
|
|
144
|
+
// Bind the boot-bound nonce (it may rotate and rewrite the state file) before any request (R06).
|
|
145
|
+
if (!this.d.nonce)
|
|
146
|
+
throw new Error("no nonce: the host state file has no usable nonce");
|
|
147
|
+
const now = this.d.now();
|
|
148
|
+
const sincePoll = now - this.lastPoll;
|
|
149
|
+
const pollDue = sincePoll >= EXECUTOR_POLL_S * 1000;
|
|
150
|
+
// Before adopt-or-kill has run, a failed poll is retried every ADOPT_RETRY_MS, not every tick.
|
|
151
|
+
let polled = false;
|
|
152
|
+
if (pollDue || (!this.adoptDone && sincePoll >= ADOPT_RETRY_MS)) {
|
|
153
|
+
const r = await this.d.api.sessions();
|
|
154
|
+
if (r.items) {
|
|
155
|
+
polled = true;
|
|
156
|
+
this.lastPoll = now;
|
|
157
|
+
this.items = new Map(r.items.map((i) => [i.id, i]));
|
|
158
|
+
this.syncGuards(r.items);
|
|
159
|
+
if (r.grantExpired !== this.grantExpired) {
|
|
160
|
+
this.grantExpired = r.grantExpired;
|
|
161
|
+
this.d.log(r.grantExpired
|
|
162
|
+
? "executor grant expired: the api lists no sessions until the owner renews it"
|
|
163
|
+
: "executor grant renewed: sessions listed again");
|
|
164
|
+
}
|
|
165
|
+
if (r.grantExpired)
|
|
166
|
+
did.push("grant_expired");
|
|
167
|
+
// A lapsed grant's empty list is not the session list: adopt-or-kill waits for a real one.
|
|
168
|
+
if (!this.adoptDone && !r.grantExpired) {
|
|
169
|
+
await this.adoptOrKill(did);
|
|
170
|
+
this.adoptDone = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.lastPoll = now;
|
|
175
|
+
did.push(`poll_failed:${r.code ?? r.status}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!this.adoptDone) {
|
|
179
|
+
this.publish("running");
|
|
180
|
+
return did;
|
|
181
|
+
}
|
|
182
|
+
await this.checkSeats(did);
|
|
183
|
+
await this.heartbeat(did);
|
|
184
|
+
if (polled && this.items.size > 0)
|
|
185
|
+
await this.claimWave(did);
|
|
186
|
+
this.publish("running");
|
|
187
|
+
return did;
|
|
188
|
+
}
|
|
189
|
+
/** Run until stop(): one tick every TICK_MS. */
|
|
190
|
+
async run(sleep) {
|
|
191
|
+
while (!this.stopped) {
|
|
192
|
+
try {
|
|
193
|
+
await this.tick();
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
this.d.log(`tick error: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
await sleep(TICK_MS);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ---- transcript guards (R38) ------------------------------------------------------------
|
|
202
|
+
syncGuards(items) {
|
|
203
|
+
const live = new Set();
|
|
204
|
+
for (const it of items) {
|
|
205
|
+
if (TERMINAL.has(it.state ?? ""))
|
|
206
|
+
continue;
|
|
207
|
+
live.add(it.id);
|
|
208
|
+
try {
|
|
209
|
+
this.d.guard(it);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
this.d.log(`transcript guard ${it.id}: ${e.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A guard whose session is no longer listed (terminal, or no longer homed here) is removed.
|
|
216
|
+
for (const id of this.d.guardedIds())
|
|
217
|
+
if (!live.has(id))
|
|
218
|
+
this.d.removeGuard(id);
|
|
219
|
+
}
|
|
220
|
+
// ---- adopt-or-kill (R11, R12) -------------------------------------------------------------
|
|
221
|
+
async adoptOrKill(did) {
|
|
222
|
+
const panes = await this.d.panes();
|
|
223
|
+
const now = this.d.now();
|
|
224
|
+
const adoptedSessions = new Set();
|
|
225
|
+
// A copy: killing a seat may change what panes() returned.
|
|
226
|
+
for (const p of [...panes]) {
|
|
227
|
+
const item = this.items.get(p.sid);
|
|
228
|
+
const claude = p.panePid && !p.dead ? this.d.claudePid(p.panePid) : null;
|
|
229
|
+
const v = adoptVerdict(p, item, now, claude !== null && this.d.alive(claude), adoptedSessions);
|
|
230
|
+
if (v.adopt && item) {
|
|
231
|
+
adoptedSessions.add(item.id);
|
|
232
|
+
const leaseSeconds = adoptedLeaseSeconds(item, now);
|
|
233
|
+
this.seats.set(item.id, {
|
|
234
|
+
sessionId: item.id,
|
|
235
|
+
name: item.name,
|
|
236
|
+
epoch: item.lease_epoch,
|
|
237
|
+
spawnId: p.spawn,
|
|
238
|
+
tmuxSession: p.session,
|
|
239
|
+
panePid: p.panePid,
|
|
240
|
+
claudePid: claude,
|
|
241
|
+
claudeGoneAt: null,
|
|
242
|
+
helloAccepted: v.hello,
|
|
243
|
+
registered: true,
|
|
244
|
+
deadline: item.lease_expires_at ? Date.parse(item.lease_expires_at) : now,
|
|
245
|
+
leaseSeconds,
|
|
246
|
+
// Heartbeat an adopted hello'd seat at once (R37: running within one period).
|
|
247
|
+
lastHeartbeat: -Infinity,
|
|
248
|
+
leaseKey: null,
|
|
249
|
+
specPath: null,
|
|
250
|
+
adopted: true,
|
|
251
|
+
interruptedTurn: null,
|
|
252
|
+
});
|
|
253
|
+
did.push(`adopt:${item.id}:${v.hello ? "hello" : "pending"}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// A current-epoch seat held here that died while the daemon was down is reported (BACKBONE C2);
|
|
257
|
+
// the server checks the spawn id, and its hello_at, not this report, is authoritative.
|
|
258
|
+
if (item &&
|
|
259
|
+
p.dead &&
|
|
260
|
+
item.lease_epoch !== null &&
|
|
261
|
+
p.epoch === item.lease_epoch &&
|
|
262
|
+
item.held_here === true) {
|
|
263
|
+
await this.reportExit(item.id, item.lease_epoch, p.spawn, exitStatusOf(p), helloLanded(item));
|
|
264
|
+
}
|
|
265
|
+
await this.d.killSeat(p.session, p.panePid);
|
|
266
|
+
did.push(`kill:${p.sid || "?"}:${v.adopt ? "?" : v.why}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ---- seat liveness, exits and hello deadlines (R17, R19, R33) ------------------------------
|
|
270
|
+
async checkSeats(did) {
|
|
271
|
+
if (this.seats.size === 0)
|
|
272
|
+
return;
|
|
273
|
+
const panes = new Map((await this.d.panes()).map((p) => [p.session, p]));
|
|
274
|
+
const now = this.d.now();
|
|
275
|
+
for (const seat of [...this.seats.values()]) {
|
|
276
|
+
const pane = panes.get(seat.tmuxSession);
|
|
277
|
+
const v = seatLiveness(seat, pane, now, (pid) => this.d.claudePid(pid), (pid) => this.d.alive(pid));
|
|
278
|
+
if (v.kind === "dead") {
|
|
279
|
+
// The claude pid is gone (a remain-on-exit pane does not count as alive, R19).
|
|
280
|
+
if (seat.registered)
|
|
281
|
+
await this.reportExit(seat.sessionId, seat.epoch, seat.spawnId, v.status, seat.helloAccepted);
|
|
282
|
+
await this.d.killSeat(seat.tmuxSession, seat.panePid);
|
|
283
|
+
this.seats.delete(seat.sessionId);
|
|
284
|
+
did.push(`exit:${seat.sessionId}:${v.status}:${seat.helloAccepted ? "hello" : "nohello"}`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!seat.helloAccepted && now >= seat.deadline) {
|
|
288
|
+
await this.d.killSeat(seat.tmuxSession, seat.panePid);
|
|
289
|
+
if (seat.registered)
|
|
290
|
+
await this.reportExit(seat.sessionId, seat.epoch, seat.spawnId, 137, false);
|
|
291
|
+
this.seats.delete(seat.sessionId);
|
|
292
|
+
did.push(`hello_deadline:${seat.sessionId}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async reportExit(sessionId, epoch, spawnId, exitStatus, helloAccepted) {
|
|
297
|
+
const r = await this.d.api.seatExit(sessionId, {
|
|
298
|
+
epoch,
|
|
299
|
+
nonce: this.d.nonce,
|
|
300
|
+
spawn_id: spawnId,
|
|
301
|
+
exit_status: Math.max(0, Math.min(255, exitStatus)),
|
|
302
|
+
hello_accepted: helloAccepted,
|
|
303
|
+
});
|
|
304
|
+
if (r.status >= 400 || r.status === 0)
|
|
305
|
+
this.d.log(`seat-exit ${sessionId} ${spawnId}: ${r.code ?? r.status}`);
|
|
306
|
+
return r;
|
|
307
|
+
}
|
|
308
|
+
// ---- heartbeat (R18, R20) -------------------------------------------------------------------
|
|
309
|
+
async heartbeat(did) {
|
|
310
|
+
const now = this.d.now();
|
|
311
|
+
const due = [...this.seats.values()].filter((s) => s.helloAccepted && now - s.lastHeartbeat >= heartbeatPeriodMs(s.leaseSeconds));
|
|
312
|
+
if (due.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
// Ruling 33.28.1: the state-file nonce once at the top level; the api fences on it.
|
|
315
|
+
const r = await this.d.api.heartbeat({
|
|
316
|
+
nonce: this.d.nonce,
|
|
317
|
+
entries: due.map((s) => ({ session_id: s.sessionId, epoch: s.epoch, spawn_id: s.spawnId })),
|
|
318
|
+
});
|
|
319
|
+
for (const s of due)
|
|
320
|
+
s.lastHeartbeat = now;
|
|
321
|
+
if (r.status !== 200 ||
|
|
322
|
+
!r.body ||
|
|
323
|
+
typeof r.body !== "object" ||
|
|
324
|
+
!Array.isArray(r.body.results)) {
|
|
325
|
+
did.push(`heartbeat_failed:${r.code ?? r.status}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const results = new Map(r.body.results.map((x) => [String(x.session_id), x]));
|
|
329
|
+
for (const s of due) {
|
|
330
|
+
const res = results.get(s.sessionId);
|
|
331
|
+
if (!res)
|
|
332
|
+
continue;
|
|
333
|
+
if (res.extended) {
|
|
334
|
+
if (res.lease_expires_at) {
|
|
335
|
+
const exp = Date.parse(res.lease_expires_at);
|
|
336
|
+
if (Number.isFinite(exp)) {
|
|
337
|
+
// The lease length the server keeps (R18): derive it from the extension.
|
|
338
|
+
const secs = Math.round((exp - now) / 1000);
|
|
339
|
+
if (secs >= 1 && secs <= 3600)
|
|
340
|
+
s.leaseSeconds = secs;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
did.push(`hb:${s.sessionId}:${res.reason ?? "extended"}`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// fenced, contained, terminal, not_hello_spawn, not_revivable: the epoch moved or the seat is not ours to keep (R20).
|
|
347
|
+
await this.d.killSeat(s.tmuxSession, s.panePid);
|
|
348
|
+
this.seats.delete(s.sessionId);
|
|
349
|
+
did.push(`hb_stop:${s.sessionId}:${res.reason ?? "?"}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ---- claim + spawn (R10, R14, R15, R16, R31, R36) -----------------------------------------------
|
|
353
|
+
inFlight() {
|
|
354
|
+
return [...this.seats.values()].filter((s) => !s.helloAccepted).length;
|
|
355
|
+
}
|
|
356
|
+
async claimWave(did) {
|
|
357
|
+
const now = this.d.now();
|
|
358
|
+
const candidates = [...this.items.values()].filter((i) => this.claimable(i, now));
|
|
359
|
+
if (candidates.length === 0)
|
|
360
|
+
return;
|
|
361
|
+
this.integrityState = await this.d.integrity();
|
|
362
|
+
if (!this.integrityState.ok) {
|
|
363
|
+
did.push(`integrity:${this.integrityState.cause}`);
|
|
364
|
+
if (this.heldFor !== this.integrityState.cause) {
|
|
365
|
+
this.heldFor = this.integrityState.cause;
|
|
366
|
+
this.d.log(`claims held: plugin integrity ${this.heldFor}`);
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (this.heldFor !== null) {
|
|
371
|
+
this.heldFor = null;
|
|
372
|
+
this.d.log("claims resumed: plugin integrity ok");
|
|
373
|
+
}
|
|
374
|
+
let started = 0;
|
|
375
|
+
for (const item of candidates) {
|
|
376
|
+
if (this.inFlight() >= RESPAWN_CONCURRENCY)
|
|
377
|
+
break;
|
|
378
|
+
const ok = await this.claimAndSpawn(item, did);
|
|
379
|
+
if (ok)
|
|
380
|
+
started++;
|
|
381
|
+
}
|
|
382
|
+
if (started > 0 && !this.firstBatchDone) {
|
|
383
|
+
this.firstBatchDone = true;
|
|
384
|
+
if (this.d.pauseAt === "after_first_batch")
|
|
385
|
+
await this.d.pause("after_first_batch");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
claimable(i, now) {
|
|
389
|
+
// Held-seat rule (U02): never claim a session whose seat this daemon holds.
|
|
390
|
+
if (this.seats.has(i.id))
|
|
391
|
+
return false;
|
|
392
|
+
if (!CLAIMABLE.has(i.state ?? ""))
|
|
393
|
+
return false;
|
|
394
|
+
if (i.state === "detached" || i.state === "parked") {
|
|
395
|
+
/* claimable */
|
|
396
|
+
}
|
|
397
|
+
else if (i.state === "resuming" || i.state === "running") {
|
|
398
|
+
// Held elsewhere (or by a seat this daemon lost): claim only after its lease expires.
|
|
399
|
+
const exp = i.lease_expires_at ? Date.parse(i.lease_expires_at) : NaN;
|
|
400
|
+
if (Number.isFinite(exp) && exp > now)
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
const u = this.unavailable.get(i.id);
|
|
404
|
+
if (u && now < u.nextAt)
|
|
405
|
+
return false;
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
async claimAndSpawn(item, did) {
|
|
409
|
+
const now = this.d.now();
|
|
410
|
+
// Nothing to resume: never claim, so no empty epoch-bumped lease is left to lapse and be re-claimed.
|
|
411
|
+
if (!item.cc_session_id) {
|
|
412
|
+
did.push(`no_cc_session:${item.id}`);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
// attest -> claim (BACKBONE C2). Attestation tokens ride on the claim when the route gives them.
|
|
416
|
+
const at = await this.d.api.attest(item.id, this.d.nonce);
|
|
417
|
+
const tokens = at.status === 200 && at.body && typeof at.body === "object"
|
|
418
|
+
? at.body.tokens
|
|
419
|
+
: undefined;
|
|
420
|
+
// mnemom-test #1001 (lead ruling, consistent with 33.28.5): a daemon with no local lease key
|
|
421
|
+
// for the session asks for one on its first claim. It never holds one here: a session it
|
|
422
|
+
// seats is not claimable, and the seat takes the key once. A bare claim and then a new_key
|
|
423
|
+
// retry never works (the bare claim makes the lease live, so the retry is refused
|
|
424
|
+
// lost_race/lease_live), so this is one claim per attempt, and any refusal waits for a tick.
|
|
425
|
+
const r = await this.d.api.claim(item.id, {
|
|
426
|
+
nonce: this.d.nonce,
|
|
427
|
+
...(tokens !== undefined ? { tokens } : {}),
|
|
428
|
+
new_key: true,
|
|
429
|
+
});
|
|
430
|
+
const c = classifyClaim(r, now);
|
|
431
|
+
if (c.kind === "ok" && c.claim.leaseKey === null) {
|
|
432
|
+
did.push(`claim_none:${item.id}:no_lease_key`);
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
if (c.kind === "unavailable") {
|
|
436
|
+
const prev = this.unavailable.get(item.id);
|
|
437
|
+
const count = (prev?.count ?? 0) + 1;
|
|
438
|
+
this.unavailable.set(item.id, {
|
|
439
|
+
cause: c.cause,
|
|
440
|
+
count,
|
|
441
|
+
nextAt: now + backoffMs(count, this.d.random),
|
|
442
|
+
});
|
|
443
|
+
did.push(`unavailable:${item.id}:${c.cause}:${count}`);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (c.kind === "none") {
|
|
447
|
+
did.push(`claim_none:${item.id}:${c.why}`);
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
this.unavailable.delete(item.id);
|
|
451
|
+
const claim = c.claim;
|
|
452
|
+
if (this.d.pauseAt === "after_claim_before_spawn")
|
|
453
|
+
await this.d.pause("after_claim_before_spawn");
|
|
454
|
+
// Transcript check after the claim (R16): restore from the guard, or fail transcript_lost. A
|
|
455
|
+
// session born locked (B5a, the claim's new_session) has none until its first turn: it starts
|
|
456
|
+
// fresh. Once a transcript exists the marker is irrelevant and the seat resumes.
|
|
457
|
+
const t = this.d.ensureTranscript(item);
|
|
458
|
+
const fresh = t.kind === "lost" && claim.newSession;
|
|
459
|
+
if (t.kind === "lost" && !fresh) {
|
|
460
|
+
const f = await this.d.api.fail(item.id, {
|
|
461
|
+
epoch: claim.epoch,
|
|
462
|
+
nonce: this.d.nonce,
|
|
463
|
+
reason: "transcript_lost",
|
|
464
|
+
});
|
|
465
|
+
did.push(`transcript_lost:${item.id}:${f.status}`);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const spawnId = randomUUID();
|
|
469
|
+
const tmuxSession = seatTmuxSession(item.id, spawnId);
|
|
470
|
+
const seat = {
|
|
471
|
+
sessionId: item.id,
|
|
472
|
+
name: item.name,
|
|
473
|
+
epoch: claim.epoch,
|
|
474
|
+
spawnId,
|
|
475
|
+
tmuxSession,
|
|
476
|
+
panePid: null,
|
|
477
|
+
claudePid: null,
|
|
478
|
+
claudeGoneAt: null,
|
|
479
|
+
helloAccepted: false,
|
|
480
|
+
registered: false,
|
|
481
|
+
deadline: claim.leaseExpiresAt,
|
|
482
|
+
leaseSeconds: claim.leaseSeconds,
|
|
483
|
+
lastHeartbeat: -Infinity,
|
|
484
|
+
leaseKey: claim.leaseKey,
|
|
485
|
+
specPath: null,
|
|
486
|
+
adopted: false,
|
|
487
|
+
// A fresh (born-locked) seat has no transcript, so no interrupted turn to continue.
|
|
488
|
+
interruptedTurn: t.kind === "lost" ? null : (this.d.interruptedAt?.(t.path) ?? null),
|
|
489
|
+
};
|
|
490
|
+
this.seats.set(item.id, seat);
|
|
491
|
+
try {
|
|
492
|
+
const sp = await this.d.spawnSeat({
|
|
493
|
+
item,
|
|
494
|
+
epoch: claim.epoch,
|
|
495
|
+
spawnId,
|
|
496
|
+
tmuxSession,
|
|
497
|
+
transcriptPath: t.kind === "lost" ? null : t.path,
|
|
498
|
+
newSession: fresh,
|
|
499
|
+
});
|
|
500
|
+
seat.panePid = sp.panePid;
|
|
501
|
+
seat.specPath = sp.specPath;
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
this.seats.delete(item.id);
|
|
505
|
+
did.push(`spawn_failed:${item.id}:${e.message}`);
|
|
506
|
+
await this.d.killSeat(tmuxSession, null);
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
if (this.d.pauseAt === "after_spawn_before_register")
|
|
510
|
+
await this.d.pause("after_spawn_before_register");
|
|
511
|
+
// Register the spawn id before the hello (R10).
|
|
512
|
+
const ap = await this.d.api.append(item.id, {
|
|
513
|
+
kind: SPAWNED_KIND,
|
|
514
|
+
refs: { spawn_id: spawnId },
|
|
515
|
+
idempotency_key: spawnIdempotencyKey(item.id, claim.epoch, spawnId),
|
|
516
|
+
expected_epoch: claim.epoch,
|
|
517
|
+
nonce: this.d.nonce,
|
|
518
|
+
});
|
|
519
|
+
if (ap.status < 200 || ap.status >= 300) {
|
|
520
|
+
// Fenced (the epoch moved) or refused: kill the seat tree; a later claim may respawn (R10).
|
|
521
|
+
await this.d.killSeat(tmuxSession, seat.panePid);
|
|
522
|
+
this.seats.delete(item.id);
|
|
523
|
+
did.push(`register_refused:${item.id}:${ap.code ?? ap.status}`);
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
seat.registered = true;
|
|
527
|
+
did.push(`spawn:${item.id}:${claim.epoch}:${claim.outcome}`);
|
|
528
|
+
if (this.d.pauseAt === "after_register_before_hello")
|
|
529
|
+
await this.d.pause("after_register_before_hello");
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
// ---- control socket ops (the seat proxy) -------------------------------------------------------
|
|
533
|
+
/** The one-time lease-key handoff to the seat proxy of `spawnId` (C3). */
|
|
534
|
+
takeLeaseKey(sessionId, spawnId) {
|
|
535
|
+
const s = this.seats.get(sessionId);
|
|
536
|
+
if (!s || s.spawnId !== spawnId || !s.leaseKey)
|
|
537
|
+
return null;
|
|
538
|
+
const k = s.leaseKey;
|
|
539
|
+
s.leaseKey = null;
|
|
540
|
+
return k;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Relay a seat's hello (R17, R32, R34). Only for a registered seat this daemon holds, with its own
|
|
544
|
+
* spawn id. 409 durable_hello_late kills that seat's tree.
|
|
545
|
+
*/
|
|
546
|
+
async relayHello(sessionId, spawnId) {
|
|
547
|
+
const s = this.seats.get(sessionId);
|
|
548
|
+
if (!s || s.spawnId !== spawnId || !s.registered)
|
|
549
|
+
return {
|
|
550
|
+
status: 404,
|
|
551
|
+
body: {
|
|
552
|
+
error: { code: SEAT_UNKNOWN, message: "no registered seat with that spawn id" },
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
const r = await this.d.api.hello(sessionId, {
|
|
556
|
+
epoch: s.epoch,
|
|
557
|
+
nonce: this.d.nonce,
|
|
558
|
+
spawn_id: spawnId,
|
|
559
|
+
});
|
|
560
|
+
if (r.status === 200) {
|
|
561
|
+
const b = (r.body ?? {});
|
|
562
|
+
if (typeof b.lease_seconds === "number" && b.lease_seconds > 0)
|
|
563
|
+
s.leaseSeconds = b.lease_seconds;
|
|
564
|
+
const first = !s.helloAccepted;
|
|
565
|
+
if (first)
|
|
566
|
+
s.lastHeartbeat = this.d.now();
|
|
567
|
+
s.helloAccepted = true;
|
|
568
|
+
// An adopted seat was resumed by an earlier daemon run; only a seat this run spawned continues.
|
|
569
|
+
if (first && !s.adopted && this.d.autoContinue)
|
|
570
|
+
void this.d
|
|
571
|
+
.autoContinue({ sessionId, tmuxSession: s.tmuxSession, epoch: s.epoch, spawnId: s.spawnId }, s.interruptedTurn)
|
|
572
|
+
.catch((e) => this.d.log(`auto-continue ${sessionId}: ${e.message}`));
|
|
573
|
+
}
|
|
574
|
+
else if (r.status === 409 && errorCode(r.body) === CODE.durable_hello_late) {
|
|
575
|
+
await this.d.killSeat(s.tmuxSession, s.panePid);
|
|
576
|
+
this.seats.delete(sessionId);
|
|
577
|
+
}
|
|
578
|
+
return { status: r.status, body: r.body };
|
|
579
|
+
}
|
|
580
|
+
// ---- status ------------------------------------------------------------------------------------
|
|
581
|
+
publish(state) {
|
|
582
|
+
this.d.publish({
|
|
583
|
+
pid: process.pid,
|
|
584
|
+
state,
|
|
585
|
+
integrity: this.integrityState,
|
|
586
|
+
seats: [...this.seats.values()].map((s) => ({
|
|
587
|
+
session_id: s.sessionId,
|
|
588
|
+
name: s.name,
|
|
589
|
+
epoch: s.epoch,
|
|
590
|
+
spawn_id: s.spawnId,
|
|
591
|
+
tmux_session: s.tmuxSession,
|
|
592
|
+
pane_pid: s.panePid,
|
|
593
|
+
hello: s.helloAccepted,
|
|
594
|
+
spec_path: s.specPath,
|
|
595
|
+
})),
|
|
596
|
+
sessions: [...this.items.values()].map((i) => ({
|
|
597
|
+
id: i.id,
|
|
598
|
+
name: i.name,
|
|
599
|
+
state: i.state,
|
|
600
|
+
state_reason: i.state_reason,
|
|
601
|
+
})),
|
|
602
|
+
unavailable: Object.fromEntries([...this.unavailable].map(([k, v]) => [k, { cause: v.cause, count: v.count }])),
|
|
603
|
+
updated_at: new Date(this.d.now()).toISOString(),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ExperimentalPrefs } from "../lib/experimental.js";
|
|
2
|
+
export declare const DURABLE_ID: "durable";
|
|
3
|
+
export declare const HOST_WHAT = "`mnemom agent host`";
|
|
4
|
+
export declare const GATED_TEXT: string;
|
|
5
|
+
/** The enable command (S1 FIX_LINES.daemon_idle body). */
|
|
6
|
+
export declare const ENABLE_FIX: string;
|
|
7
|
+
/** C12-R40: durable on only through the shell. */
|
|
8
|
+
export declare const INSTALL_SHELL_ONLY_TEXT: string;
|
|
9
|
+
/** C12-R39: the idle line (status and the launcher). */
|
|
10
|
+
export declare const IDLE_TEXT: string;
|
|
11
|
+
/** The idle line with its fix, rendered from S1 (C12-R39). */
|
|
12
|
+
export declare const IDLE_FIX: string;
|
|
13
|
+
export declare const IDLE_LOG = "durable disabled: idle";
|
|
14
|
+
/** config.json's experimental block, read fresh (a missing or corrupt file is off). */
|
|
15
|
+
export declare function readStoredExperimental(configFile: string): ExperimentalPrefs | undefined;
|
|
16
|
+
/** The daemon gate: config.json only, env ignored (C12-R39). */
|
|
17
|
+
export declare function durableOnInConfig(configFile: string): boolean;
|
|
18
|
+
/** The fresh-invocation gate: config.json and the environment (C12-R25). */
|
|
19
|
+
export declare function durableOnFresh(configFile: string, env: Readonly<Record<string, string | undefined>>): boolean;
|
|
20
|
+
export type InstallGate = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
exit: 1 | 2;
|
|
25
|
+
message: string;
|
|
26
|
+
};
|
|
27
|
+
/** `host install`: off everywhere -> exit 2 gated; on only in the shell -> exit 1 (R40); on in config -> ok. */
|
|
28
|
+
export declare function installGate(configFile: string, env: Readonly<Record<string, string | undefined>>): InstallGate;
|
|
29
|
+
export type HostArgvGate = {
|
|
30
|
+
code: 2;
|
|
31
|
+
message: string;
|
|
32
|
+
} | null;
|
|
33
|
+
/**
|
|
34
|
+
* The `host` verb-word gate (C17-R10, C12-R25), applied to the raw argv before commander runs,
|
|
35
|
+
* so that neither the bare word, nor a trailing `--help`/`--json`, nor an unknown sub-word
|
|
36
|
+
* (`host breakglass`) reaches commander's help, usage or unknown-command paths while durable is
|
|
37
|
+
* off. Reads config.json only; writes nothing, sends nothing, spawns nothing.
|
|
38
|
+
*
|
|
39
|
+
* The hidden `run` keeps its carve-out: the gate comes from config.json only (env ignored, R39),
|
|
40
|
+
* and it refuses only when there is no enrollment state file; an enrolled host goes on to take
|
|
41
|
+
* the lock and idle, which `run` itself decides.
|
|
42
|
+
*/
|
|
43
|
+
export declare function hostArgvGate(argv: readonly string[], ctx: {
|
|
44
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
45
|
+
home: string;
|
|
46
|
+
}): HostArgvGate;
|