instar 1.3.61 → 1.3.63
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/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +176 -10
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +36 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +45 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ReapGuard.d.ts +79 -0
- package/dist/core/ReapGuard.d.ts.map +1 -0
- package/dist/core/ReapGuard.js +114 -0
- package/dist/core/ReapGuard.js.map +1 -0
- package/dist/core/SessionLivenessOracle.d.ts +104 -0
- package/dist/core/SessionLivenessOracle.d.ts.map +1 -0
- package/dist/core/SessionLivenessOracle.js +201 -0
- package/dist/core/SessionLivenessOracle.js.map +1 -0
- package/dist/core/SessionManager.d.ts +90 -10
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +200 -40
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +10 -0
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +12 -2
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/types.d.ts +70 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/ContextWedgeSentinel.d.ts +118 -0
- package/dist/monitoring/ContextWedgeSentinel.d.ts.map +1 -0
- package/dist/monitoring/ContextWedgeSentinel.js +238 -0
- package/dist/monitoring/ContextWedgeSentinel.js.map +1 -0
- package/dist/monitoring/ReapLog.d.ts +51 -0
- package/dist/monitoring/ReapLog.d.ts.map +1 -0
- package/dist/monitoring/ReapLog.js +82 -0
- package/dist/monitoring/ReapLog.js.map +1 -0
- package/dist/monitoring/ReapNotifier.d.ts +66 -0
- package/dist/monitoring/ReapNotifier.d.ts.map +1 -0
- package/dist/monitoring/ReapNotifier.js +112 -0
- package/dist/monitoring/ReapNotifier.js.map +1 -0
- package/dist/monitoring/SentinelNotifier.d.ts +1 -1
- package/dist/monitoring/SentinelNotifier.d.ts.map +1 -1
- package/dist/monitoring/SentinelNotifier.js.map +1 -1
- package/dist/monitoring/SessionReaper.d.ts +4 -0
- package/dist/monitoring/SessionReaper.d.ts.map +1 -1
- package/dist/monitoring/SessionReaper.js +19 -42
- package/dist/monitoring/SessionReaper.js.map +1 -1
- package/dist/monitoring/StaleSessionBackstop.d.ts +89 -0
- package/dist/monitoring/StaleSessionBackstop.d.ts.map +1 -0
- package/dist/monitoring/StaleSessionBackstop.js +176 -0
- package/dist/monitoring/StaleSessionBackstop.js.map +1 -0
- package/dist/monitoring/sentinelWiring.d.ts +23 -0
- package/dist/monitoring/sentinelWiring.d.ts.map +1 -1
- package/dist/monitoring/sentinelWiring.js +36 -0
- package/dist/monitoring/sentinelWiring.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +5 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +3 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +1 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +33 -11
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/scaffold/templates.ts +5 -0
- package/upgrades/1.3.62.md +87 -0
- package/upgrades/1.3.63.md +87 -0
- package/upgrades/side-effects/context-wedge-sentinel.md +69 -0
- package/upgrades/side-effects/unified-session-lifecycle-robustness.md +241 -0
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* and can be monitored/reaped by the server.
|
|
7
7
|
*/
|
|
8
8
|
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { SessionLivenessOracle } from './SessionLivenessOracle.js';
|
|
10
|
+
import type { ReapGuard } from './ReapGuard.js';
|
|
9
11
|
/** Diagnostics for a single running session */
|
|
10
12
|
export interface SessionDiagnostic {
|
|
11
13
|
name: string;
|
|
@@ -59,6 +61,13 @@ export declare class SessionManager extends EventEmitter {
|
|
|
59
61
|
private subagentChecker?;
|
|
60
62
|
/** Optional callback: is this session currently in active compaction recovery? If so, skip zombie kill. */
|
|
61
63
|
private activeRecoveryChecker?;
|
|
64
|
+
/** Shared stateless KEEP-guard consulted by terminateSession (§P2). */
|
|
65
|
+
private reapGuard?;
|
|
66
|
+
/** Sessions the §P5 backstop has flagged long-`indeterminate` — excluded from
|
|
67
|
+
* the ABSOLUTE spawn cap so unverifiable panes can't lock out spawning. */
|
|
68
|
+
private longIndeterminateSessions;
|
|
69
|
+
/** Multi-machine lease/awake predicate gating autonomous reaps (§Multi-machine). */
|
|
70
|
+
private isAwakeMachine?;
|
|
62
71
|
/** Optional callback: is this tmux session currently bound to a live messaging topic
|
|
63
72
|
* (Telegram/Slack/iMessage)? Returns a stable identifier (e.g. topic ID or channel ID)
|
|
64
73
|
* when bound, null otherwise. When bound, the zombie-kill threshold is extended via
|
|
@@ -100,6 +109,15 @@ export declare class SessionManager extends EventEmitter {
|
|
|
100
109
|
/** Per-session shim directory root (one subdir per session). Used for K9 mandatory shim. */
|
|
101
110
|
private shimRoot;
|
|
102
111
|
constructor(config: SessionManagerConfig, state: StateManager);
|
|
112
|
+
/** Lazily-constructed tri-state liveness oracle (UNIFIED-SESSION-LIFECYCLE §P1).
|
|
113
|
+
* Backs the boot purge and isSessionAliveAsync so a slow/unreachable tmux is
|
|
114
|
+
* treated as `indeterminate`, never `dead`. */
|
|
115
|
+
private _livenessOracle;
|
|
116
|
+
private get livenessOracle();
|
|
117
|
+
/** Test/DI seam: inject a liveness oracle (e.g. one with a scripted exec).
|
|
118
|
+
* Production builds the oracle lazily from config; this lets unit tests drive
|
|
119
|
+
* the alive/dead/indeterminate verdicts deterministically. */
|
|
120
|
+
setLivenessOracle(oracle: SessionLivenessOracle): void;
|
|
103
121
|
/** Effective idle-at-prompt kill threshold (config override or hardcoded default) */
|
|
104
122
|
private get effectiveIdleKillMinutes();
|
|
105
123
|
/** Effective idle-at-prompt kill threshold for sessions bound to a live messaging topic.
|
|
@@ -157,6 +175,41 @@ export declare class SessionManager extends EventEmitter {
|
|
|
157
175
|
* window. Wired by CompactionSentinel at server startup.
|
|
158
176
|
*/
|
|
159
177
|
setActiveRecoveryChecker(checker: (session: Session) => boolean): void;
|
|
178
|
+
/**
|
|
179
|
+
* Register the shared ReapGuard (UNIFIED-SESSION-LIFECYCLE §P2). Once set,
|
|
180
|
+
* every AUTONOMOUS terminate consults it: a non-null KEEP reason makes the
|
|
181
|
+
* terminate a no-op `{ terminated:false, skipped:<reason> }`. Wired in server.ts
|
|
182
|
+
* with the same signal closures that back SessionReaper, so a killer can only
|
|
183
|
+
* *request* a kill — the authority refuses to end a guarded session. Operator
|
|
184
|
+
* kills bypass the guard. Unset (tests/standalone) → no guard consult.
|
|
185
|
+
*/
|
|
186
|
+
setReapGuard(guard: ReapGuard): void;
|
|
187
|
+
/**
|
|
188
|
+
* Flag/unflag a session as long-`indeterminate` (UNIFIED-SESSION-LIFECYCLE §P5).
|
|
189
|
+
* Driven by the StaleSessionBackstop. A flagged session still counts toward the
|
|
190
|
+
* soft scheduler cap, but is excluded from the ABSOLUTE `maxSessions × 3` cap so
|
|
191
|
+
* a fleet of unverifiable panes can never lock a human out of spawning.
|
|
192
|
+
*/
|
|
193
|
+
markLongIndeterminate(sessionId: string, isLong: boolean): void;
|
|
194
|
+
/**
|
|
195
|
+
* Resolve tri-state liveness for many sessions from ONE tmux snapshot via the
|
|
196
|
+
* P1 oracle (UNIFIED-SESSION-LIFECYCLE §P5 backstop). `reachable` is false when
|
|
197
|
+
* the snapshot was non-authoritative (control plane unreachable) — in which
|
|
198
|
+
* case every session resolves `indeterminate`. Shares the oracle's short-TTL
|
|
199
|
+
* cache, so the backstop and boot-purge never double-probe within a tick.
|
|
200
|
+
*/
|
|
201
|
+
probeLivenessBatch(tmuxSessions: string[]): Promise<{
|
|
202
|
+
reachable: boolean;
|
|
203
|
+
liveness: Map<string, 'alive' | 'dead' | 'indeterminate'>;
|
|
204
|
+
}>;
|
|
205
|
+
/**
|
|
206
|
+
* Register the multi-machine lease/awake predicate. When set and it returns
|
|
207
|
+
* false, an AUTONOMOUS terminate is a no-op `skipped:'not-lease-holder'` — only
|
|
208
|
+
* the awake/lease-holding machine may autonomously reap; a standby may detect
|
|
209
|
+
* and signal but never kill another machine's sessions. Operator kills bypass.
|
|
210
|
+
* Unset → treated as awake (single-machine default).
|
|
211
|
+
*/
|
|
212
|
+
setAwakeChecker(fn: () => boolean): void;
|
|
160
213
|
/**
|
|
161
214
|
* Set the Prompt Gate InputDetector for prompt monitoring.
|
|
162
215
|
* When set, monitorTick() will capture output and feed it to the detector.
|
|
@@ -190,20 +243,40 @@ export declare class SessionManager extends EventEmitter {
|
|
|
190
243
|
/** True if a reap is in flight for this session. */
|
|
191
244
|
isReaping(sessionId: string): boolean;
|
|
192
245
|
/**
|
|
193
|
-
* Single-writer session termination
|
|
194
|
-
*
|
|
195
|
-
* killed at most once
|
|
246
|
+
* Single-writer session termination — the sole ReapAuthority
|
|
247
|
+
* (UNIFIED-SESSION-LIFECYCLE §P0, building on SESSION-REAPER-SPEC §3.6). EVERY
|
|
248
|
+
* autonomous killer funnels through this so a session is killed at most once,
|
|
249
|
+
* with exactly-once `beforeSessionKill`/`sessionComplete`/`sessionReaped`
|
|
196
250
|
* emission and a correct `endedReason`.
|
|
197
251
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
252
|
+
* The authority holds the safety checks so a killer can only *request* a kill:
|
|
253
|
+
* - CAS on live status + an in-flight guard (prevents the double-kill race).
|
|
254
|
+
* - `protectedSessions` (never kill).
|
|
255
|
+
* - Lease-holder gate: an AUTONOMOUS reap on a standby machine is a no-op
|
|
256
|
+
* `skipped:'not-lease-holder'` — only the awake machine reaps.
|
|
257
|
+
* - ReapGuard (§P2): an AUTONOMOUS reap of a session the guard says to KEEP is
|
|
258
|
+
* a no-op `skipped:<keep-reason>` — even a buggy killer cannot end a guarded
|
|
259
|
+
* session.
|
|
260
|
+
* `origin:'operator'` bypasses the lease-gate and the guard (an explicit human
|
|
261
|
+
* kill must always happen). Default `origin` is `'autonomous'` so an in-process
|
|
262
|
+
* caller can never accidentally mint operator privilege.
|
|
263
|
+
*
|
|
264
|
+
* Re-entrancy: the guard is consulted BEFORE this call acquires its own
|
|
265
|
+
* in-flight lock, so the authority's lock for the kill it is performing is
|
|
266
|
+
* never misread by the guard.
|
|
267
|
+
*
|
|
268
|
+
* `disposition:'recovery-bounce'` marks a kill-to-respawn (SessionRecovery,
|
|
269
|
+
* version-skew, context-exhaustion) so the §P3 notifier stays silent — that is
|
|
270
|
+
* a bounce, not a disappearance. Default `'terminal'`.
|
|
201
271
|
*
|
|
202
272
|
* @returns `{ terminated: true }` on a kill performed by THIS call, or
|
|
203
273
|
* `{ terminated: false, skipped }` describing why it was a no-op.
|
|
204
274
|
*/
|
|
205
275
|
terminateSession(sessionId: string, reason: string, opts?: {
|
|
206
276
|
finalStatus?: 'completed' | 'killed';
|
|
277
|
+
disposition?: 'terminal' | 'recovery-bounce';
|
|
278
|
+
origin?: 'operator' | 'autonomous';
|
|
279
|
+
knownDead?: boolean;
|
|
207
280
|
}): Promise<{
|
|
208
281
|
terminated: boolean;
|
|
209
282
|
skipped?: string;
|
|
@@ -355,10 +428,17 @@ export declare class SessionManager extends EventEmitter {
|
|
|
355
428
|
sessions: Session[];
|
|
356
429
|
};
|
|
357
430
|
/**
|
|
358
|
-
* Fast startup purge —
|
|
359
|
-
* Called once at server boot BEFORE monitoring starts, to prevent the death
|
|
360
|
-
* where stale
|
|
361
|
-
*
|
|
431
|
+
* Fast startup purge — remove session records for sessions that are *definitively*
|
|
432
|
+
* dead. Called once at server boot BEFORE monitoring starts, to prevent the death
|
|
433
|
+
* spiral where stale records overwhelm startup and block health checks.
|
|
434
|
+
*
|
|
435
|
+
* UNIFIED-SESSION-LIFECYCLE §P1 (the 2026-05-27 fix): liveness is resolved from a
|
|
436
|
+
* single `tmux list-sessions` via the tri-state oracle, NOT a per-session
|
|
437
|
+
* `has-session` with a 1s timeout. The old code treated a timeout (tmux busy at
|
|
438
|
+
* boot) identically to "session gone" and mass-purged live sessions — "9 of 9".
|
|
439
|
+
* Now ONLY a `dead` verdict (server reachable + exact id absent) purges; `alive`
|
|
440
|
+
* and `indeterminate` are kept. An indeterminate session lingers one extra tick
|
|
441
|
+
* (cheap, caught by monitoring) rather than being falsely reaped (expensive).
|
|
362
442
|
*/
|
|
363
443
|
purgeDeadSessions(): Promise<number>;
|
|
364
444
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SessionManager.d.ts","sourceRoot":"","sources":["../../src/core/SessionManager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"SessionManager.d.ts","sourceRoot":"","sources":["../../src/core/SessionManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { SessionLivenessOracle } from './SessionLivenessOracle.js';
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
import { DegradationReporter } from '../monitoring/DegradationReporter.js';
|
|
18
19
|
import { detectRateLimited } from '../monitoring/rateLimitDetection.js';
|
|
@@ -94,6 +95,13 @@ export class SessionManager extends EventEmitter {
|
|
|
94
95
|
subagentChecker;
|
|
95
96
|
/** Optional callback: is this session currently in active compaction recovery? If so, skip zombie kill. */
|
|
96
97
|
activeRecoveryChecker;
|
|
98
|
+
/** Shared stateless KEEP-guard consulted by terminateSession (§P2). */
|
|
99
|
+
reapGuard;
|
|
100
|
+
/** Sessions the §P5 backstop has flagged long-`indeterminate` — excluded from
|
|
101
|
+
* the ABSOLUTE spawn cap so unverifiable panes can't lock out spawning. */
|
|
102
|
+
longIndeterminateSessions = new Set();
|
|
103
|
+
/** Multi-machine lease/awake predicate gating autonomous reaps (§Multi-machine). */
|
|
104
|
+
isAwakeMachine;
|
|
97
105
|
/** Optional callback: is this tmux session currently bound to a live messaging topic
|
|
98
106
|
* (Telegram/Slack/iMessage)? Returns a stable identifier (e.g. topic ID or channel ID)
|
|
99
107
|
* when bound, null otherwise. When bound, the zombie-kill threshold is extended via
|
|
@@ -139,6 +147,22 @@ export class SessionManager extends EventEmitter {
|
|
|
139
147
|
this.config = config;
|
|
140
148
|
this.state = state;
|
|
141
149
|
}
|
|
150
|
+
/** Lazily-constructed tri-state liveness oracle (UNIFIED-SESSION-LIFECYCLE §P1).
|
|
151
|
+
* Backs the boot purge and isSessionAliveAsync so a slow/unreachable tmux is
|
|
152
|
+
* treated as `indeterminate`, never `dead`. */
|
|
153
|
+
_livenessOracle = null;
|
|
154
|
+
get livenessOracle() {
|
|
155
|
+
if (!this._livenessOracle) {
|
|
156
|
+
this._livenessOracle = new SessionLivenessOracle({ tmuxPath: this.config.tmuxPath, exec: execFileAsync }, this.config.liveness);
|
|
157
|
+
}
|
|
158
|
+
return this._livenessOracle;
|
|
159
|
+
}
|
|
160
|
+
/** Test/DI seam: inject a liveness oracle (e.g. one with a scripted exec).
|
|
161
|
+
* Production builds the oracle lazily from config; this lets unit tests drive
|
|
162
|
+
* the alive/dead/indeterminate verdicts deterministically. */
|
|
163
|
+
setLivenessOracle(oracle) {
|
|
164
|
+
this._livenessOracle = oracle;
|
|
165
|
+
}
|
|
142
166
|
/** Effective idle-at-prompt kill threshold (config override or hardcoded default) */
|
|
143
167
|
get effectiveIdleKillMinutes() {
|
|
144
168
|
return this.config.idlePromptKillMinutes ?? FALLBACK_IDLE_PROMPT_KILL_MINUTES;
|
|
@@ -250,6 +274,57 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
250
274
|
setActiveRecoveryChecker(checker) {
|
|
251
275
|
this.activeRecoveryChecker = checker;
|
|
252
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Register the shared ReapGuard (UNIFIED-SESSION-LIFECYCLE §P2). Once set,
|
|
279
|
+
* every AUTONOMOUS terminate consults it: a non-null KEEP reason makes the
|
|
280
|
+
* terminate a no-op `{ terminated:false, skipped:<reason> }`. Wired in server.ts
|
|
281
|
+
* with the same signal closures that back SessionReaper, so a killer can only
|
|
282
|
+
* *request* a kill — the authority refuses to end a guarded session. Operator
|
|
283
|
+
* kills bypass the guard. Unset (tests/standalone) → no guard consult.
|
|
284
|
+
*/
|
|
285
|
+
setReapGuard(guard) {
|
|
286
|
+
this.reapGuard = guard;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Flag/unflag a session as long-`indeterminate` (UNIFIED-SESSION-LIFECYCLE §P5).
|
|
290
|
+
* Driven by the StaleSessionBackstop. A flagged session still counts toward the
|
|
291
|
+
* soft scheduler cap, but is excluded from the ABSOLUTE `maxSessions × 3` cap so
|
|
292
|
+
* a fleet of unverifiable panes can never lock a human out of spawning.
|
|
293
|
+
*/
|
|
294
|
+
markLongIndeterminate(sessionId, isLong) {
|
|
295
|
+
if (isLong)
|
|
296
|
+
this.longIndeterminateSessions.add(sessionId);
|
|
297
|
+
else
|
|
298
|
+
this.longIndeterminateSessions.delete(sessionId);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve tri-state liveness for many sessions from ONE tmux snapshot via the
|
|
302
|
+
* P1 oracle (UNIFIED-SESSION-LIFECYCLE §P5 backstop). `reachable` is false when
|
|
303
|
+
* the snapshot was non-authoritative (control plane unreachable) — in which
|
|
304
|
+
* case every session resolves `indeterminate`. Shares the oracle's short-TTL
|
|
305
|
+
* cache, so the backstop and boot-purge never double-probe within a tick.
|
|
306
|
+
*/
|
|
307
|
+
async probeLivenessBatch(tmuxSessions) {
|
|
308
|
+
const results = await this.livenessOracle.probeAll(tmuxSessions);
|
|
309
|
+
const liveness = new Map();
|
|
310
|
+
for (const [name, r] of results)
|
|
311
|
+
liveness.set(name, r.liveness);
|
|
312
|
+
// With a single authoritative snapshot, a session is only `indeterminate`
|
|
313
|
+
// when the whole snapshot was non-authoritative — i.e. the server is
|
|
314
|
+
// unreachable. So "reachable" ⟺ at least one definitive (alive/dead) verdict.
|
|
315
|
+
const reachable = tmuxSessions.length === 0 || [...liveness.values()].some((l) => l !== 'indeterminate');
|
|
316
|
+
return { reachable, liveness };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Register the multi-machine lease/awake predicate. When set and it returns
|
|
320
|
+
* false, an AUTONOMOUS terminate is a no-op `skipped:'not-lease-holder'` — only
|
|
321
|
+
* the awake/lease-holding machine may autonomously reap; a standby may detect
|
|
322
|
+
* and signal but never kill another machine's sessions. Operator kills bypass.
|
|
323
|
+
* Unset → treated as awake (single-machine default).
|
|
324
|
+
*/
|
|
325
|
+
setAwakeChecker(fn) {
|
|
326
|
+
this.isAwakeMachine = fn;
|
|
327
|
+
}
|
|
253
328
|
/**
|
|
254
329
|
* Set the Prompt Gate InputDetector for prompt monitoring.
|
|
255
330
|
* When set, monitorTick() will capture output and feed it to the detector.
|
|
@@ -307,14 +382,31 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
307
382
|
return this.reapingSessions.has(sessionId);
|
|
308
383
|
}
|
|
309
384
|
/**
|
|
310
|
-
* Single-writer session termination
|
|
311
|
-
*
|
|
312
|
-
* killed at most once
|
|
385
|
+
* Single-writer session termination — the sole ReapAuthority
|
|
386
|
+
* (UNIFIED-SESSION-LIFECYCLE §P0, building on SESSION-REAPER-SPEC §3.6). EVERY
|
|
387
|
+
* autonomous killer funnels through this so a session is killed at most once,
|
|
388
|
+
* with exactly-once `beforeSessionKill`/`sessionComplete`/`sessionReaped`
|
|
313
389
|
* emission and a correct `endedReason`.
|
|
314
390
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
391
|
+
* The authority holds the safety checks so a killer can only *request* a kill:
|
|
392
|
+
* - CAS on live status + an in-flight guard (prevents the double-kill race).
|
|
393
|
+
* - `protectedSessions` (never kill).
|
|
394
|
+
* - Lease-holder gate: an AUTONOMOUS reap on a standby machine is a no-op
|
|
395
|
+
* `skipped:'not-lease-holder'` — only the awake machine reaps.
|
|
396
|
+
* - ReapGuard (§P2): an AUTONOMOUS reap of a session the guard says to KEEP is
|
|
397
|
+
* a no-op `skipped:<keep-reason>` — even a buggy killer cannot end a guarded
|
|
398
|
+
* session.
|
|
399
|
+
* `origin:'operator'` bypasses the lease-gate and the guard (an explicit human
|
|
400
|
+
* kill must always happen). Default `origin` is `'autonomous'` so an in-process
|
|
401
|
+
* caller can never accidentally mint operator privilege.
|
|
402
|
+
*
|
|
403
|
+
* Re-entrancy: the guard is consulted BEFORE this call acquires its own
|
|
404
|
+
* in-flight lock, so the authority's lock for the kill it is performing is
|
|
405
|
+
* never misread by the guard.
|
|
406
|
+
*
|
|
407
|
+
* `disposition:'recovery-bounce'` marks a kill-to-respawn (SessionRecovery,
|
|
408
|
+
* version-skew, context-exhaustion) so the §P3 notifier stays silent — that is
|
|
409
|
+
* a bounce, not a disappearance. Default `'terminal'`.
|
|
318
410
|
*
|
|
319
411
|
* @returns `{ terminated: true }` on a kill performed by THIS call, or
|
|
320
412
|
* `{ terminated: false, skipped }` describing why it was a no-op.
|
|
@@ -327,8 +419,42 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
327
419
|
if (session.status !== 'running' && session.status !== 'starting') {
|
|
328
420
|
return { terminated: false, skipped: `already-${session.status}` };
|
|
329
421
|
}
|
|
330
|
-
|
|
331
|
-
|
|
422
|
+
const origin = opts?.origin ?? 'autonomous';
|
|
423
|
+
const disposition = opts?.disposition ?? 'terminal';
|
|
424
|
+
// ── Authority gates (autonomous only; operator bypasses) ──
|
|
425
|
+
// An `origin:'operator'` kill — stamped ONLY by the Bearer-authed HTTP route
|
|
426
|
+
// layer — must always happen: the user clicked "kill". It bypasses protected,
|
|
427
|
+
// the lease gate, and the KEEP-guard. Autonomous killers (the default) can
|
|
428
|
+
// only *request*; the authority holds the safety checks.
|
|
429
|
+
if (origin === 'autonomous') {
|
|
430
|
+
// Protected set — never autonomously reap a protected session.
|
|
431
|
+
if (this.config.protectedSessions.includes(session.tmuxSession)) {
|
|
432
|
+
this.emit('reapBlocked', { session, reason, skipped: 'protected', origin });
|
|
433
|
+
return { terminated: false, skipped: 'protected' };
|
|
434
|
+
}
|
|
435
|
+
// Lease-holder gate: a standby machine never reaps another machine's sessions.
|
|
436
|
+
if (this.isAwakeMachine && !this.isAwakeMachine()) {
|
|
437
|
+
this.emit('reapBlocked', { session, reason, skipped: 'not-lease-holder', origin });
|
|
438
|
+
return { terminated: false, skipped: 'not-lease-holder' };
|
|
439
|
+
}
|
|
440
|
+
// ReapGuard: refuse to end a session the guard says to KEEP. Consulted
|
|
441
|
+
// before the in-flight lock is acquired (re-entrancy ordering).
|
|
442
|
+
//
|
|
443
|
+
// `knownDead` bypass (boot-purge, #1): the caller has already proven the
|
|
444
|
+
// session is `dead` via the P1 oracle (tmux server reachable + exact id
|
|
445
|
+
// absent). The guard exists to protect a LIVE-but-busy session from being
|
|
446
|
+
// mistaken for dead — its topic-state KEEP reasons (recent-user-message,
|
|
447
|
+
// open-commitment) are liveness-blind, so consulting it on a proven-dead
|
|
448
|
+
// session would pin a tombstone in the running list and re-create the boot
|
|
449
|
+
// death-spiral the purge guards against. A proven-dead session has no
|
|
450
|
+
// liveness to protect, so skip the guard. Lease + protected + CAS still apply.
|
|
451
|
+
if (!opts?.knownDead) {
|
|
452
|
+
const blocked = this.reapGuard?.blockedReason(session);
|
|
453
|
+
if (blocked) {
|
|
454
|
+
this.emit('reapBlocked', { session, reason, skipped: blocked.reason, origin });
|
|
455
|
+
return { terminated: false, skipped: blocked.reason };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
332
458
|
}
|
|
333
459
|
// In-flight guard: a concurrent terminate already owns this session.
|
|
334
460
|
if (this.terminating.has(sessionId)) {
|
|
@@ -348,6 +474,9 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
348
474
|
session.endedReason = reason;
|
|
349
475
|
this.state.saveSession(session);
|
|
350
476
|
this.emit('sessionComplete', session);
|
|
477
|
+
// The single reap-notification signal (§P3): terminal reaps may reach the
|
|
478
|
+
// user; recovery-bounce reaps are silent. One emission, at the one chokepoint.
|
|
479
|
+
this.emit('sessionReaped', { session, reason, disposition, origin });
|
|
351
480
|
this.idlePromptSince.delete(session.id);
|
|
352
481
|
this.reapingSessions.delete(session.id);
|
|
353
482
|
return { terminated: true };
|
|
@@ -527,20 +656,17 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
527
656
|
injectedAt: pendingInjection.injectedAt,
|
|
528
657
|
});
|
|
529
658
|
}
|
|
530
|
-
console.warn(`[SessionManager] Session "${session.name}" exceeded timeout (${Math.round(elapsed)}m > ${maxMinutes}m) and is idle.
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
session.endedAt = new Date().toISOString();
|
|
542
|
-
this.state.saveSession(session);
|
|
543
|
-
this.emit('sessionComplete', session);
|
|
659
|
+
console.warn(`[SessionManager] Session "${session.name}" exceeded timeout (${Math.round(elapsed)}m > ${maxMinutes}m) and is idle. Requesting kill via ReapAuthority.`);
|
|
660
|
+
// Route through the single ReapAuthority (§P0) instead of an inline
|
|
661
|
+
// kill: this restores the beforeSessionKill/sessionComplete emission,
|
|
662
|
+
// adds the sessionReaped signal + reap-log entry, applies the lease
|
|
663
|
+
// gate, and — critically — gains the P2 KEEP-guard's topic-bound
|
|
664
|
+
// grace, so a session that just received a user message is not
|
|
665
|
+
// age-killed out from under the user.
|
|
666
|
+
await this.terminateSession(session.id, 'age-limit', {
|
|
667
|
+
finalStatus: 'killed',
|
|
668
|
+
disposition: 'terminal',
|
|
669
|
+
});
|
|
544
670
|
continue;
|
|
545
671
|
}
|
|
546
672
|
}
|
|
@@ -646,8 +772,11 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
646
772
|
const bindingNote = binding != null ? ` (topic-bound, threshold ${killThresholdMinutes}m)` : ` (threshold ${killThresholdMinutes}m)`;
|
|
647
773
|
console.warn(`[SessionManager] Session "${session.name}" idle at prompt for ${Math.round(idleMs / 60_000)}m with no active processes${bindingNote}. Killing zombie.`);
|
|
648
774
|
// Funnel through the single-writer path so the reaper and this
|
|
649
|
-
// idle-kill can never double-kill or double-emit (§3.6).
|
|
650
|
-
|
|
775
|
+
// idle-kill can never double-kill or double-emit (§3.6). Explicit
|
|
776
|
+
// terminal disposition → the §P3 notifier surfaces it to the user.
|
|
777
|
+
await this.terminateSession(session.id, 'idle-zombie', {
|
|
778
|
+
disposition: 'terminal',
|
|
779
|
+
});
|
|
651
780
|
continue;
|
|
652
781
|
}
|
|
653
782
|
}
|
|
@@ -1170,32 +1299,58 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1170
1299
|
return { count: this._cachedRunningCount, sessions: this._cachedRunningSessions };
|
|
1171
1300
|
}
|
|
1172
1301
|
/**
|
|
1173
|
-
* Fast startup purge —
|
|
1174
|
-
* Called once at server boot BEFORE monitoring starts, to prevent the death
|
|
1175
|
-
* where stale
|
|
1176
|
-
*
|
|
1302
|
+
* Fast startup purge — remove session records for sessions that are *definitively*
|
|
1303
|
+
* dead. Called once at server boot BEFORE monitoring starts, to prevent the death
|
|
1304
|
+
* spiral where stale records overwhelm startup and block health checks.
|
|
1305
|
+
*
|
|
1306
|
+
* UNIFIED-SESSION-LIFECYCLE §P1 (the 2026-05-27 fix): liveness is resolved from a
|
|
1307
|
+
* single `tmux list-sessions` via the tri-state oracle, NOT a per-session
|
|
1308
|
+
* `has-session` with a 1s timeout. The old code treated a timeout (tmux busy at
|
|
1309
|
+
* boot) identically to "session gone" and mass-purged live sessions — "9 of 9".
|
|
1310
|
+
* Now ONLY a `dead` verdict (server reachable + exact id absent) purges; `alive`
|
|
1311
|
+
* and `indeterminate` are kept. An indeterminate session lingers one extra tick
|
|
1312
|
+
* (cheap, caught by monitoring) rather than being falsely reaped (expensive).
|
|
1177
1313
|
*/
|
|
1178
1314
|
async purgeDeadSessions() {
|
|
1179
1315
|
const running = this.state.listSessions({ status: 'running' });
|
|
1180
1316
|
if (running.length === 0)
|
|
1181
1317
|
return 0;
|
|
1318
|
+
const verdicts = await this.livenessOracle.probeAll(running.map((s) => s.tmuxSession));
|
|
1182
1319
|
let purged = 0;
|
|
1320
|
+
let kept = 0;
|
|
1321
|
+
let indeterminate = 0;
|
|
1322
|
+
let skipped = 0;
|
|
1183
1323
|
for (const session of running) {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1324
|
+
const v = verdicts.get(session.tmuxSession);
|
|
1325
|
+
if (v?.liveness === 'dead') {
|
|
1326
|
+
// Route through the single ReapAuthority (§P0) so the reap is lease-gated,
|
|
1327
|
+
// recorded in the reap-log, and emits the standard lifecycle events. The
|
|
1328
|
+
// KEEP-guard is bypassed via `knownDead` — the oracle has already proven
|
|
1329
|
+
// this session dead, and the guard's liveness-blind topic-state KEEPs would
|
|
1330
|
+
// otherwise pin a tombstone and re-create the death-spiral.
|
|
1331
|
+
const r = await this.terminateSession(session.id, 'boot-purge-dead', {
|
|
1332
|
+
knownDead: true,
|
|
1333
|
+
finalStatus: 'completed',
|
|
1187
1334
|
});
|
|
1335
|
+
if (r.terminated)
|
|
1336
|
+
purged++;
|
|
1337
|
+
else
|
|
1338
|
+
skipped++; // not-lease-holder / protected — recorded, kept for next awake tick
|
|
1188
1339
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
this.state.saveSession(session);
|
|
1194
|
-
purged++;
|
|
1340
|
+
else {
|
|
1341
|
+
kept++;
|
|
1342
|
+
if (v?.liveness === 'indeterminate')
|
|
1343
|
+
indeterminate++;
|
|
1195
1344
|
}
|
|
1196
1345
|
}
|
|
1197
|
-
if (purged > 0) {
|
|
1198
|
-
console.log(`[SessionManager] Startup purge: removed ${purged} dead session(s) of ${running.length} tracked`
|
|
1346
|
+
if (purged > 0 || indeterminate > 0 || skipped > 0) {
|
|
1347
|
+
console.log(`[SessionManager] Startup purge: removed ${purged} dead session(s) of ${running.length} tracked` +
|
|
1348
|
+
(indeterminate > 0
|
|
1349
|
+
? ` (${indeterminate} indeterminate — KEPT, will re-verify on the next tick rather than risk a false purge)`
|
|
1350
|
+
: '') +
|
|
1351
|
+
(skipped > 0
|
|
1352
|
+
? ` (${skipped} dead but not reaped here — standby/protected, deferred to the awake machine)`
|
|
1353
|
+
: ''));
|
|
1199
1354
|
}
|
|
1200
1355
|
return purged;
|
|
1201
1356
|
}
|
|
@@ -1401,7 +1556,12 @@ rm() { "${shimRunner}" rm "$@"; }
|
|
|
1401
1556
|
// Safety valve: still cap at maxSessions * 3 to prevent runaway sessions.
|
|
1402
1557
|
const runningSessions = this.listRunningSessions();
|
|
1403
1558
|
const absoluteLimit = this.config.maxSessions * 3;
|
|
1404
|
-
|
|
1559
|
+
// §P5: exclude long-`indeterminate` sessions (unverifiable panes flagged by the
|
|
1560
|
+
// StaleSessionBackstop) from the ABSOLUTE cap, so a fleet of them can never lock
|
|
1561
|
+
// a human out of spawning — the death-spiral the boot purge guarded against
|
|
1562
|
+
// cannot relocate here.
|
|
1563
|
+
const countable = runningSessions.filter(s => !this.longIndeterminateSessions.has(s.id));
|
|
1564
|
+
if (countable.length >= absoluteLimit) {
|
|
1405
1565
|
throw new Error(`Absolute session limit (${absoluteLimit}) reached. ` +
|
|
1406
1566
|
`Running: ${runningSessions.map(s => s.name).join(', ')}`);
|
|
1407
1567
|
}
|