@celestea/runtime 2.7.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/agent-config.d.ts +18 -0
  4. package/dist/agent-config.js +31 -0
  5. package/dist/autowake.d.ts +141 -0
  6. package/dist/autowake.js +262 -0
  7. package/dist/compact/index.d.ts +13 -0
  8. package/dist/compact/index.js +13 -0
  9. package/dist/compact/plan.d.ts +51 -0
  10. package/dist/compact/plan.js +98 -0
  11. package/dist/compact/rewrite.d.ts +23 -0
  12. package/dist/compact/rewrite.js +79 -0
  13. package/dist/compact/run.d.ts +44 -0
  14. package/dist/compact/run.js +59 -0
  15. package/dist/compact/summarize.d.ts +30 -0
  16. package/dist/compact/summarize.js +70 -0
  17. package/dist/compact/transcript.d.ts +35 -0
  18. package/dist/compact/transcript.js +88 -0
  19. package/dist/compose.d.ts +117 -0
  20. package/dist/compose.js +191 -0
  21. package/dist/errors.d.ts +25 -0
  22. package/dist/errors.js +34 -0
  23. package/dist/frames.d.ts +46 -0
  24. package/dist/frames.js +62 -0
  25. package/dist/gen.d.ts +86 -0
  26. package/dist/gen.js +129 -0
  27. package/dist/host/engine-session.d.ts +117 -0
  28. package/dist/host/engine-session.js +109 -0
  29. package/dist/host/index.d.ts +39 -0
  30. package/dist/host/index.js +39 -0
  31. package/dist/host/provider-target.d.ts +113 -0
  32. package/dist/host/provider-target.js +116 -0
  33. package/dist/inbox-checkpoint.d.ts +18 -0
  34. package/dist/inbox-checkpoint.js +37 -0
  35. package/dist/inbox.d.ts +94 -0
  36. package/dist/inbox.js +139 -0
  37. package/dist/index.d.ts +71 -0
  38. package/dist/index.js +71 -0
  39. package/dist/ledger-io.d.ts +27 -0
  40. package/dist/ledger-io.js +74 -0
  41. package/dist/ledger-llm.d.ts +48 -0
  42. package/dist/ledger-llm.js +115 -0
  43. package/dist/ledger-query.d.ts +91 -0
  44. package/dist/ledger-query.js +153 -0
  45. package/dist/ledger.d.ts +271 -0
  46. package/dist/ledger.js +444 -0
  47. package/dist/pricing.d.ts +100 -0
  48. package/dist/pricing.js +167 -0
  49. package/dist/profile.d.ts +26 -0
  50. package/dist/profile.js +39 -0
  51. package/dist/recovery.d.ts +56 -0
  52. package/dist/recovery.js +91 -0
  53. package/dist/retention.d.ts +49 -0
  54. package/dist/retention.js +119 -0
  55. package/dist/runtime.d.ts +197 -0
  56. package/dist/runtime.js +347 -0
  57. package/dist/sanitize.d.ts +35 -0
  58. package/dist/sanitize.js +36 -0
  59. package/dist/session-binding.d.ts +36 -0
  60. package/dist/session-binding.js +33 -0
  61. package/dist/session-registry.d.ts +238 -0
  62. package/dist/session-registry.js +388 -0
  63. package/dist/status.d.ts +279 -0
  64. package/dist/status.js +411 -0
  65. package/dist/tokens.d.ts +25 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/turn-runner.d.ts +169 -0
  68. package/dist/turn-runner.js +242 -0
  69. package/dist/usage.d.ts +64 -0
  70. package/dist/usage.js +88 -0
  71. package/dist/watchdog-mount.d.ts +79 -0
  72. package/dist/watchdog-mount.js +120 -0
  73. package/dist/worker-wiring.d.ts +74 -0
  74. package/dist/worker-wiring.js +107 -0
  75. package/package.json +31 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Session binding — how a generation is tied to ONE conversation.
3
+ *
4
+ * A binding is (session id + directory + a way to open the log). Keeping the
5
+ * opener as a callback is what lets the runtime stay out of the persistence
6
+ * business: `packages/session` owns JSONL replay/repair, the host passes
7
+ * `() => PersistentSessionLog.open(dir, id)`, and the runtime only knows the
8
+ * `SessionLog` seam.
9
+ *
10
+ * Rebinding re-opens the SAME directory/id (`binding.open()` again) and
11
+ * re-provides the result under `SESSION_LOG_SERVICE`; a later `provide` of the
12
+ * same token replaces the earlier one, so every consumer that resolves the
13
+ * service lazily (the agent loop does, per turn) sees the new log while an
14
+ * in-flight reader keeps the object it already holds.
15
+ */
16
+ import { SESSION_LOG_SERVICE } from "@celestea/core";
17
+ import { ComposeError } from "./errors.js";
18
+ /** Ergonomic constructor for a binding (a plain object literal also works). */
19
+ export function createSessionBinding(spec) {
20
+ return { sessionId: spec.sessionId, dir: spec.dir ?? null, open: spec.open };
21
+ }
22
+ /**
23
+ * Open the binding and provide it into the context (last `provide` wins). The
24
+ * opened log is returned so the caller can keep a direct handle.
25
+ */
26
+ export function bindSession(ctx, binding) {
27
+ const log = binding.open();
28
+ if (log === undefined || log === null) {
29
+ throw new ComposeError(`session binding '${binding.sessionId}' produced no log`);
30
+ }
31
+ ctx.provide(SESSION_LOG_SERVICE, log);
32
+ return log;
33
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * SessionRuntimeRegistry — `session id -> independent Runtime instance` (W513).
3
+ *
4
+ * The registry is what removes the "single active session" from the engine: no
5
+ * generation is global any more, every session owns its own composition, its own
6
+ * busy slot, its own status/usage tracker, its own session log and its own
7
+ * inbox. Instances are created lazily (`ensure`), reused while the profile epoch
8
+ * is current, rebuilt at the next turn boundary when it is not, and reclaimed
9
+ * when idle (LRU + TTL).
10
+ *
11
+ * Invariants (all of them are tested):
12
+ * - `ensure` is idempotent: two calls return the SAME instance, so a session
13
+ * can never be composed twice and two writers can never target one log;
14
+ * - busy is PER INSTANCE: the runner's slot was always per runtime, the
15
+ * registry only stops treating any turn as "the process is busy";
16
+ * - an in-flight instance is never rebuilt and never evicted — a running turn
17
+ * is not interrupted by a config change, by LRU pressure or by a neighbour's
18
+ * traffic;
19
+ * - capacity is explicit: over `maxLive` the registry reclaims idle instances
20
+ * first and throws [SessionCapacityError] (503 + Retry-After at the host)
21
+ * when every instance is busy; over `maxConcurrentTurns` a NEW turn is
22
+ * refused with [TurnCapacityError] instead of being silently queued.
23
+ *
24
+ * W742 (the two lifecycle promises the audit found unimplemented):
25
+ * - §1 REBUILD RESPECTS LIVE WORK. A rebuild disposes the old instance, which
26
+ * shuts the composed runtime down — aborting in-flight workers and dropping
27
+ * their registry rows. An instance that still holds live background work
28
+ * (see `rebuildDeferred`) is therefore only MARKED when the epoch bumps, and
29
+ * [settleDeferred] recomposes it once that work has ended. The host owns the
30
+ * callback that decides what "live work" means; the registry owns the order.
31
+ * - §2 THE IDLE TTL IS REAL. [startReclaimer] arms ONE low-frequency, `unref`ed
32
+ * timer that runs [sweep] (deferred rebuilds + [evictIdle]), so
33
+ * `CELESTEA_SESSION_IDLE_TTL_MS` actually reclaims something; [shutdown]
34
+ * disarms it, so no timer outlives the engine it belongs to.
35
+ */
36
+ import type { TurnOutcome } from "@celestea/core";
37
+ import type { Runtime } from "./runtime.js";
38
+ /** Registry key of the "no session" runtime (turns without an active session). */
39
+ export declare const DETACHED_SESSION_KEY = "<detached>";
40
+ /** One session's whole runtime state: the instance plus its own slots. */
41
+ export interface SessionRuntime {
42
+ /** Registry key (`sessionId`, or [DETACHED_SESSION_KEY] when null). */
43
+ readonly key: string;
44
+ readonly sessionId: string | null;
45
+ /** Session directory (null = detached/in-memory). */
46
+ dir: string | null;
47
+ /** Profile epoch the instance was composed from. */
48
+ profileEpoch: number;
49
+ runtime: Runtime;
50
+ /**
51
+ * Turns started on THIS session (per-session numbering, contract §4.1),
52
+ * seeded from the session log at `ensure` / `rebuild` (E §1.3 P0 ④).
53
+ */
54
+ turnNo: number;
55
+ /** In-flight turn's cancel handle (null between turns). */
56
+ controller: AbortController | null;
57
+ inFlight: boolean;
58
+ /** Terminal state of the last turn on this session (diagnostics). */
59
+ lastOutcome: TurnOutcome | null;
60
+ /** LRU stamp (updated by ensure / beginTurn / endTurn). */
61
+ lastActiveAt: number;
62
+ /** Set when a config epoch bumped while the instance was busy. */
63
+ needsRebuild: boolean;
64
+ /**
65
+ * W794: the host DETACHED this instance because the session it belonged to was
66
+ * deleted ([SessionRuntimeRegistry.release]). The entry is out of the registry
67
+ * by then, but a turn that was in flight still holds this object — the flag is
68
+ * how its owner knows to stop publishing frames for a session that no longer
69
+ * exists (and to skip reading a released runtime's statusline).
70
+ */
71
+ detached: boolean;
72
+ }
73
+ export interface SessionRegistryDeps {
74
+ /** Compose one instance (the host injects `compose(...)` here). */
75
+ build: (sessionId: string | null, dir: string | null, epoch: number) => Runtime;
76
+ /** Tear one instance down (shutdown + release); never called on a busy one. */
77
+ dispose: (runtime: Runtime) => Promise<void> | void;
78
+ /** Current profile epoch; an instance behind it is rebuilt on next use. */
79
+ currentEpoch?: () => number;
80
+ /** Live-instance cap (0 = unlimited). */
81
+ maxLive?: number;
82
+ /** Concurrent-turn cap across every instance (0 = unlimited). */
83
+ maxConcurrentTurns?: number;
84
+ /** Idle TTL for [SessionRuntimeRegistry.evictIdle]; 0 = never by TTL. */
85
+ idleTtlMs?: number;
86
+ /**
87
+ * W742 §2: reclaimer period for [SessionRuntimeRegistry.startReclaimer];
88
+ * <= 0 (or omitted) = derive it from `idleTtlMs` (a quarter of the TTL, at
89
+ * least 1s, 0 when the TTL itself is 0 = nothing to reclaim).
90
+ */
91
+ reclaimerMs?: number;
92
+ /**
93
+ * `true` pins an instance: never reclaimed and not counted against `maxLive`.
94
+ * The host pins the detached default instance and every session that OWNS
95
+ * worker rows. Whether an instance may be REBUILT is a different question, with
96
+ * its own callback ([SessionRegistryDeps.rebuildDeferred]).
97
+ */
98
+ pinned?: (entry: SessionRuntime) => boolean;
99
+ /**
100
+ * W742 §1: `true` = disposing this instance would kill LIVE background work
101
+ * (unsettled workers). Its rebuild is DEFERRED, never skipped: the epoch bump
102
+ * only marks the instance and [SessionRuntimeRegistry.settleDeferred] (or the
103
+ * reclaimer's [SessionRuntimeRegistry.sweep]) recomposes it as soon as this
104
+ * returns false. Absent = every rebuild is allowed (tests, embedded hosts).
105
+ */
106
+ rebuildDeferred?: (entry: SessionRuntime) => boolean;
107
+ now?: () => number;
108
+ }
109
+ /** Raised when no live-instance slot can be made free (host maps it to 503). */
110
+ export declare class SessionCapacityError extends Error {
111
+ readonly kind = "session_capacity";
112
+ readonly limit: number;
113
+ constructor(limit: number);
114
+ }
115
+ /** Raised when the concurrent-turn cap is reached (host maps it to 503). */
116
+ export declare class TurnCapacityError extends Error {
117
+ readonly kind = "turn_capacity";
118
+ readonly limit: number;
119
+ constructor(limit: number);
120
+ }
121
+ /** Key of a session id (`null` = the detached instance). */
122
+ export declare function keyOfSession(sessionId: string | null): string;
123
+ export declare class SessionRuntimeRegistry {
124
+ private readonly entries;
125
+ private readonly deps;
126
+ private readonly now;
127
+ /** W742 §2: the armed low-frequency reclaimer (null = disarmed). */
128
+ private timer;
129
+ constructor(deps: SessionRegistryDeps);
130
+ /** Live instance count. */
131
+ get size(): number;
132
+ /** The session's instance, or null (never creates one). */
133
+ peek(sessionId: string | null): SessionRuntime | null;
134
+ /** Every live instance, in creation order. */
135
+ list(): readonly SessionRuntime[];
136
+ /** Session ids of the live instances (detached reported as `null`-free list). */
137
+ liveSessionIds(): string[];
138
+ /** Session ids with an in-flight turn. */
139
+ busySessionIds(): string[];
140
+ inFlightCount(): number;
141
+ /** The session's instance, creating it (and making room) when needed. */
142
+ ensure(sessionId: string | null, dir: string | null): SessionRuntime;
143
+ /** Grab this session's turn slot; returns the session-local turn number. */
144
+ beginTurn(entry: SessionRuntime, controller: AbortController): number;
145
+ /** Release this session's turn slot and record the terminal state. */
146
+ endTurn(entry: SessionRuntime, outcome: TurnOutcome | null): void;
147
+ /**
148
+ * Config epoch changed: idle instances are recomposed at once, busy ones are
149
+ * MARKED and rebuild at their next turn boundary (a running turn is never
150
+ * interrupted and never sees a half-swapped profile).
151
+ */
152
+ invalidateAll(): void;
153
+ /**
154
+ * ONE session's generation changed (W516: its `grants.json` was written).
155
+ * The security boundary of a session instance is fixed for the whole turn, so
156
+ * an idle instance is recomposed immediately while a busy one is marked and
157
+ * rebuilds at its next turn boundary — exactly like a config epoch bump, but
158
+ * scoped: neighbours keep their instances and their own boundaries.
159
+ */
160
+ invalidateSession(sessionId: string | null): boolean;
161
+ /** Reclaim idle instances past the TTL; returns the reclaimed keys. */
162
+ evictIdle(): Promise<string[]>;
163
+ /** Reclaim one instance (false when it is busy, pinned or unknown). */
164
+ evict(key: string): Promise<boolean>;
165
+ /**
166
+ * W794: DROP one instance the host has just deleted the session of.
167
+ *
168
+ * This is the FORCED counterpart of [evict]: the session id is gone, so no
169
+ * rule that exists to protect a LIVE session may keep its instance alive — not
170
+ * `inFlight` (the host aborted the turn first, and a cooperative abort is not
171
+ * instantaneous) and not `pinned` (the pin exists so an idle sweep cannot kill
172
+ * live background work of a session that is still there; a deleted session's
173
+ * workers must die with it).
174
+ *
175
+ * The instance is marked [SessionRuntime.detached] BEFORE the teardown, so any
176
+ * frame the in-flight turn produces while it unwinds can be recognized as
177
+ * belonging to a session that no longer exists. A teardown failure is swallowed:
178
+ * the session is being removed either way, and resurrecting it as a live entry
179
+ * would be worse than leaking a half-disposed generation.
180
+ *
181
+ * `null` (the detached default generation) can never be released here.
182
+ */
183
+ release(sessionId: string | null): Promise<boolean>;
184
+ /**
185
+ * W742 §1: recompose every marked instance whose live work has ENDED — the
186
+ * counterpart of the deferral in [settleEpoch]. Returns the keys that were
187
+ * actually rebuilt, so the host can report the generation swap it just did.
188
+ */
189
+ settleDeferred(): string[];
190
+ /**
191
+ * W742 §2: ONE reclaimer pass. The TTL is applied FIRST: a stale instance that
192
+ * is past its idle deadline is reclaimed, so it is never recomposed just to be
193
+ * thrown away a moment later; what is still warm then gets its deferred
194
+ * generation swap.
195
+ */
196
+ sweep(): Promise<{
197
+ rebuilt: string[];
198
+ evicted: string[];
199
+ }>;
200
+ /**
201
+ * W742 §2: arm the low-frequency reclaimer (idempotent). Returns false when a
202
+ * timer is already armed or when nothing could ever be reclaimed. The timer is
203
+ * `unref`ed — it must never keep the process alive — and [shutdown] disarms it,
204
+ * so a reclaimed engine leaks no timer. A sweep failure is swallowed on
205
+ * purpose: a reclaimer must never take the process down (see `Watchdog`).
206
+ */
207
+ startReclaimer(intervalMs?: number): boolean;
208
+ /** Disarm the reclaimer (idempotent; [shutdown] calls it). */
209
+ stopReclaimer(): void;
210
+ /** Is the reclaimer armed? (`CELESTEA_SESSION_IDLE_TTL_MS` > 0 in the host.) */
211
+ get reclaimerRunning(): boolean;
212
+ /** Tear every instance down and disarm the reclaimer (process exit / tests). */
213
+ shutdown(): Promise<void>;
214
+ /** Live / in-flight / started-turn counters (resource governance). */
215
+ stats(): {
216
+ live: number;
217
+ inFlight: number;
218
+ turns: number;
219
+ };
220
+ private epoch;
221
+ private isPinned;
222
+ /** W742 §1: would a rebuild of this instance kill live background work? */
223
+ private rebuildIsDeferred;
224
+ /** W742 §2: the derived period (a quarter of the TTL, floor 1s). */
225
+ private reclaimerDefault;
226
+ /** Rebuild now when idle AND free of live work, else mark for a later sweep. */
227
+ private settleEpoch;
228
+ private rebuild;
229
+ /**
230
+ * Free one slot for `key`: LRU over idle, unpinned instances. Pinned
231
+ * instances (live background workers) and the detached default runtime do not
232
+ * consume the session cap, so orchestration cannot starve the UI sessions.
233
+ */
234
+ private makeRoom;
235
+ /** Live instances that count against `maxLive` (pinned ones do not). */
236
+ private countedLive;
237
+ private lruVictim;
238
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * SessionRuntimeRegistry — `session id -> independent Runtime instance` (W513).
3
+ *
4
+ * The registry is what removes the "single active session" from the engine: no
5
+ * generation is global any more, every session owns its own composition, its own
6
+ * busy slot, its own status/usage tracker, its own session log and its own
7
+ * inbox. Instances are created lazily (`ensure`), reused while the profile epoch
8
+ * is current, rebuilt at the next turn boundary when it is not, and reclaimed
9
+ * when idle (LRU + TTL).
10
+ *
11
+ * Invariants (all of them are tested):
12
+ * - `ensure` is idempotent: two calls return the SAME instance, so a session
13
+ * can never be composed twice and two writers can never target one log;
14
+ * - busy is PER INSTANCE: the runner's slot was always per runtime, the
15
+ * registry only stops treating any turn as "the process is busy";
16
+ * - an in-flight instance is never rebuilt and never evicted — a running turn
17
+ * is not interrupted by a config change, by LRU pressure or by a neighbour's
18
+ * traffic;
19
+ * - capacity is explicit: over `maxLive` the registry reclaims idle instances
20
+ * first and throws [SessionCapacityError] (503 + Retry-After at the host)
21
+ * when every instance is busy; over `maxConcurrentTurns` a NEW turn is
22
+ * refused with [TurnCapacityError] instead of being silently queued.
23
+ *
24
+ * W742 (the two lifecycle promises the audit found unimplemented):
25
+ * - §1 REBUILD RESPECTS LIVE WORK. A rebuild disposes the old instance, which
26
+ * shuts the composed runtime down — aborting in-flight workers and dropping
27
+ * their registry rows. An instance that still holds live background work
28
+ * (see `rebuildDeferred`) is therefore only MARKED when the epoch bumps, and
29
+ * [settleDeferred] recomposes it once that work has ended. The host owns the
30
+ * callback that decides what "live work" means; the registry owns the order.
31
+ * - §2 THE IDLE TTL IS REAL. [startReclaimer] arms ONE low-frequency, `unref`ed
32
+ * timer that runs [sweep] (deferred rebuilds + [evictIdle]), so
33
+ * `CELESTEA_SESSION_IDLE_TTL_MS` actually reclaims something; [shutdown]
34
+ * disarms it, so no timer outlives the engine it belongs to.
35
+ */
36
+ import { nextTurnNumber } from "@celestea/session";
37
+ import { migrateReceipts } from "./gen.js";
38
+ import { HOST_SESSION_ID } from "./tokens.js";
39
+ /** Registry key of the "no session" runtime (turns without an active session). */
40
+ export const DETACHED_SESSION_KEY = "<detached>";
41
+ /** Raised when no live-instance slot can be made free (host maps it to 503). */
42
+ export class SessionCapacityError extends Error {
43
+ kind = "session_capacity";
44
+ limit;
45
+ constructor(limit) {
46
+ super(`too many live sessions (limit ${limit})`);
47
+ this.name = "SessionCapacityError";
48
+ this.limit = limit;
49
+ }
50
+ }
51
+ /** Raised when the concurrent-turn cap is reached (host maps it to 503). */
52
+ export class TurnCapacityError extends Error {
53
+ kind = "turn_capacity";
54
+ limit;
55
+ constructor(limit) {
56
+ super(`too many concurrent turns (limit ${limit})`);
57
+ this.name = "TurnCapacityError";
58
+ this.limit = limit;
59
+ }
60
+ }
61
+ /**
62
+ * The session-local turn number, restored FROM THE LOG (E §1.3 P0 ④): the log
63
+ * owns the `turn-<n>` counter, so `maxTurnNumber(events)+1` is the next number
64
+ * this session would have used and the counter can never restart at 0 across a
65
+ * process restart (`POST /api/turn`'s `turn` stays comparable with the ids in
66
+ * `cli-main.jsonl`). A brand new session has no events -> 0, exactly as before.
67
+ */
68
+ function turnNumberFromLog(runtime) {
69
+ return nextTurnNumber(runtime.session.events());
70
+ }
71
+ /** Key of a session id (`null` = the detached instance). */
72
+ export function keyOfSession(sessionId) {
73
+ return sessionId ?? DETACHED_SESSION_KEY;
74
+ }
75
+ export class SessionRuntimeRegistry {
76
+ entries = new Map();
77
+ deps;
78
+ now;
79
+ /** W742 §2: the armed low-frequency reclaimer (null = disarmed). */
80
+ timer = null;
81
+ constructor(deps) {
82
+ this.deps = deps;
83
+ this.now = deps.now ?? Date.now;
84
+ }
85
+ /** Live instance count. */
86
+ get size() {
87
+ return this.entries.size;
88
+ }
89
+ /** The session's instance, or null (never creates one). */
90
+ peek(sessionId) {
91
+ return this.entries.get(keyOfSession(sessionId)) ?? null;
92
+ }
93
+ /** Every live instance, in creation order. */
94
+ list() {
95
+ return [...this.entries.values()];
96
+ }
97
+ /** Session ids of the live instances (detached reported as `null`-free list). */
98
+ liveSessionIds() {
99
+ return this.list()
100
+ .map((e) => e.sessionId)
101
+ .filter((id) => id !== null);
102
+ }
103
+ /** Session ids with an in-flight turn. */
104
+ busySessionIds() {
105
+ return this.list()
106
+ .filter((e) => e.inFlight)
107
+ .map((e) => e.sessionId)
108
+ .filter((id) => id !== null);
109
+ }
110
+ inFlightCount() {
111
+ return this.list().filter((e) => e.inFlight).length;
112
+ }
113
+ /** The session's instance, creating it (and making room) when needed. */
114
+ ensure(sessionId, dir) {
115
+ const key = keyOfSession(sessionId);
116
+ const existing = this.entries.get(key);
117
+ if (existing !== undefined) {
118
+ existing.lastActiveAt = this.now();
119
+ if (existing.dir === null && dir !== null)
120
+ existing.dir = dir;
121
+ this.settleEpoch(existing);
122
+ return existing;
123
+ }
124
+ this.makeRoom(key, key === DETACHED_SESSION_KEY);
125
+ const epoch = this.epoch();
126
+ const runtime = this.deps.build(sessionId, dir, epoch);
127
+ const entry = {
128
+ key,
129
+ sessionId,
130
+ dir,
131
+ profileEpoch: epoch,
132
+ runtime,
133
+ turnNo: turnNumberFromLog(runtime),
134
+ controller: null,
135
+ inFlight: false,
136
+ lastOutcome: null,
137
+ lastActiveAt: this.now(),
138
+ needsRebuild: false,
139
+ detached: false,
140
+ };
141
+ this.entries.set(key, entry);
142
+ return entry;
143
+ }
144
+ /** Grab this session's turn slot; returns the session-local turn number. */
145
+ beginTurn(entry, controller) {
146
+ const max = this.deps.maxConcurrentTurns ?? 0;
147
+ if (max > 0 && this.inFlightCount() >= max)
148
+ throw new TurnCapacityError(max);
149
+ entry.inFlight = true;
150
+ entry.controller = controller;
151
+ entry.turnNo += 1;
152
+ entry.lastOutcome = null;
153
+ entry.lastActiveAt = this.now();
154
+ return entry.turnNo;
155
+ }
156
+ /** Release this session's turn slot and record the terminal state. */
157
+ endTurn(entry, outcome) {
158
+ entry.inFlight = false;
159
+ entry.controller = null;
160
+ entry.lastOutcome = outcome;
161
+ entry.lastActiveAt = this.now();
162
+ }
163
+ /**
164
+ * Config epoch changed: idle instances are recomposed at once, busy ones are
165
+ * MARKED and rebuild at their next turn boundary (a running turn is never
166
+ * interrupted and never sees a half-swapped profile).
167
+ */
168
+ invalidateAll() {
169
+ for (const entry of this.entries.values()) {
170
+ entry.needsRebuild = entry.needsRebuild || entry.profileEpoch < this.epoch() || entry.inFlight;
171
+ this.settleEpoch(entry);
172
+ }
173
+ }
174
+ /**
175
+ * ONE session's generation changed (W516: its `grants.json` was written).
176
+ * The security boundary of a session instance is fixed for the whole turn, so
177
+ * an idle instance is recomposed immediately while a busy one is marked and
178
+ * rebuilds at its next turn boundary — exactly like a config epoch bump, but
179
+ * scoped: neighbours keep their instances and their own boundaries.
180
+ */
181
+ invalidateSession(sessionId) {
182
+ const entry = this.entries.get(keyOfSession(sessionId));
183
+ if (entry === undefined)
184
+ return false;
185
+ entry.needsRebuild = true;
186
+ this.settleEpoch(entry);
187
+ return true;
188
+ }
189
+ /** Reclaim idle instances past the TTL; returns the reclaimed keys. */
190
+ async evictIdle() {
191
+ const ttl = this.deps.idleTtlMs ?? 0;
192
+ if (ttl <= 0)
193
+ return [];
194
+ const deadline = this.now() - ttl;
195
+ const keys = this.list()
196
+ .filter((e) => e.lastActiveAt <= deadline)
197
+ .map((e) => e.key);
198
+ const evicted = [];
199
+ for (const key of keys)
200
+ if (await this.evict(key))
201
+ evicted.push(key);
202
+ return evicted;
203
+ }
204
+ /** Reclaim one instance (false when it is busy, pinned or unknown). */
205
+ async evict(key) {
206
+ const entry = this.entries.get(key);
207
+ if (entry === undefined || entry.inFlight || this.isPinned(entry))
208
+ return false;
209
+ this.entries.delete(key);
210
+ await this.deps.dispose(entry.runtime);
211
+ return true;
212
+ }
213
+ /**
214
+ * W794: DROP one instance the host has just deleted the session of.
215
+ *
216
+ * This is the FORCED counterpart of [evict]: the session id is gone, so no
217
+ * rule that exists to protect a LIVE session may keep its instance alive — not
218
+ * `inFlight` (the host aborted the turn first, and a cooperative abort is not
219
+ * instantaneous) and not `pinned` (the pin exists so an idle sweep cannot kill
220
+ * live background work of a session that is still there; a deleted session's
221
+ * workers must die with it).
222
+ *
223
+ * The instance is marked [SessionRuntime.detached] BEFORE the teardown, so any
224
+ * frame the in-flight turn produces while it unwinds can be recognized as
225
+ * belonging to a session that no longer exists. A teardown failure is swallowed:
226
+ * the session is being removed either way, and resurrecting it as a live entry
227
+ * would be worse than leaking a half-disposed generation.
228
+ *
229
+ * `null` (the detached default generation) can never be released here.
230
+ */
231
+ async release(sessionId) {
232
+ if (sessionId === null)
233
+ return false;
234
+ const key = keyOfSession(sessionId);
235
+ const entry = this.entries.get(key);
236
+ if (entry === undefined)
237
+ return false;
238
+ this.entries.delete(key);
239
+ entry.detached = true;
240
+ try {
241
+ await this.deps.dispose(entry.runtime);
242
+ }
243
+ catch {
244
+ // See above: the removal wins over a failing teardown.
245
+ }
246
+ return true;
247
+ }
248
+ /**
249
+ * W742 §1: recompose every marked instance whose live work has ENDED — the
250
+ * counterpart of the deferral in [settleEpoch]. Returns the keys that were
251
+ * actually rebuilt, so the host can report the generation swap it just did.
252
+ */
253
+ settleDeferred() {
254
+ const rebuilt = [];
255
+ for (const entry of this.entries.values()) {
256
+ if (!entry.needsRebuild || entry.inFlight || this.rebuildIsDeferred(entry))
257
+ continue;
258
+ this.rebuild(entry);
259
+ rebuilt.push(entry.key);
260
+ }
261
+ return rebuilt;
262
+ }
263
+ /**
264
+ * W742 §2: ONE reclaimer pass. The TTL is applied FIRST: a stale instance that
265
+ * is past its idle deadline is reclaimed, so it is never recomposed just to be
266
+ * thrown away a moment later; what is still warm then gets its deferred
267
+ * generation swap.
268
+ */
269
+ async sweep() {
270
+ const evicted = await this.evictIdle();
271
+ const rebuilt = this.settleDeferred();
272
+ return { rebuilt, evicted };
273
+ }
274
+ /**
275
+ * W742 §2: arm the low-frequency reclaimer (idempotent). Returns false when a
276
+ * timer is already armed or when nothing could ever be reclaimed. The timer is
277
+ * `unref`ed — it must never keep the process alive — and [shutdown] disarms it,
278
+ * so a reclaimed engine leaks no timer. A sweep failure is swallowed on
279
+ * purpose: a reclaimer must never take the process down (see `Watchdog`).
280
+ */
281
+ startReclaimer(intervalMs) {
282
+ const every = intervalMs ?? this.deps.reclaimerMs ?? this.reclaimerDefault();
283
+ if (this.timer !== null || every <= 0)
284
+ return false;
285
+ this.timer = setInterval(() => void this.sweep().catch(() => undefined), every);
286
+ this.timer.unref();
287
+ return true;
288
+ }
289
+ /** Disarm the reclaimer (idempotent; [shutdown] calls it). */
290
+ stopReclaimer() {
291
+ if (this.timer === null)
292
+ return;
293
+ clearInterval(this.timer);
294
+ this.timer = null;
295
+ }
296
+ /** Is the reclaimer armed? (`CELESTEA_SESSION_IDLE_TTL_MS` > 0 in the host.) */
297
+ get reclaimerRunning() {
298
+ return this.timer !== null;
299
+ }
300
+ /** Tear every instance down and disarm the reclaimer (process exit / tests). */
301
+ async shutdown() {
302
+ this.stopReclaimer();
303
+ const entries = [...this.entries.values()];
304
+ this.entries.clear();
305
+ for (const entry of entries)
306
+ await this.deps.dispose(entry.runtime);
307
+ }
308
+ /** Live / in-flight / started-turn counters (resource governance). */
309
+ stats() {
310
+ let turns = 0;
311
+ for (const entry of this.entries.values())
312
+ turns += entry.turnNo;
313
+ return { live: this.entries.size, inFlight: this.inFlightCount(), turns };
314
+ }
315
+ epoch() {
316
+ return this.deps.currentEpoch?.() ?? 0;
317
+ }
318
+ isPinned(entry) {
319
+ return this.deps.pinned?.(entry) === true;
320
+ }
321
+ /** W742 §1: would a rebuild of this instance kill live background work? */
322
+ rebuildIsDeferred(entry) {
323
+ return this.deps.rebuildDeferred?.(entry) === true;
324
+ }
325
+ /** W742 §2: the derived period (a quarter of the TTL, floor 1s). */
326
+ reclaimerDefault() {
327
+ const ttl = this.deps.idleTtlMs ?? 0;
328
+ return ttl <= 0 ? 0 : Math.max(1_000, Math.floor(ttl / 4));
329
+ }
330
+ /** Rebuild now when idle AND free of live work, else mark for a later sweep. */
331
+ settleEpoch(entry) {
332
+ if (!entry.needsRebuild && entry.profileEpoch >= this.epoch())
333
+ return;
334
+ if (entry.inFlight || this.rebuildIsDeferred(entry)) {
335
+ entry.needsRebuild = true;
336
+ return;
337
+ }
338
+ this.rebuild(entry);
339
+ }
340
+ rebuild(entry) {
341
+ const previous = entry.runtime;
342
+ entry.runtime = this.deps.build(entry.sessionId, entry.dir, this.epoch());
343
+ // W769: a worker receipt that landed in this session's mailbox while the
344
+ // generation was being swapped must not die with the generation it was
345
+ // addressed to (the same migration `GenerationHub.swapSync` performs).
346
+ migrateReceipts(previous, entry.runtime, entry.sessionId ?? HOST_SESSION_ID);
347
+ entry.profileEpoch = this.epoch();
348
+ entry.needsRebuild = false;
349
+ entry.turnNo = turnNumberFromLog(entry.runtime);
350
+ entry.lastOutcome = null;
351
+ void this.deps.dispose(previous);
352
+ }
353
+ /**
354
+ * Free one slot for `key`: LRU over idle, unpinned instances. Pinned
355
+ * instances (live background workers) and the detached default runtime do not
356
+ * consume the session cap, so orchestration cannot starve the UI sessions.
357
+ */
358
+ makeRoom(key, detached) {
359
+ const max = this.deps.maxLive ?? 0;
360
+ if (max <= 0 || detached)
361
+ return;
362
+ while (this.countedLive() >= max) {
363
+ const victim = this.lruVictim(key);
364
+ if (victim === null)
365
+ throw new SessionCapacityError(max);
366
+ this.entries.delete(victim.key);
367
+ void this.deps.dispose(victim.runtime);
368
+ }
369
+ }
370
+ /** Live instances that count against `maxLive` (pinned ones do not). */
371
+ countedLive() {
372
+ let live = 0;
373
+ for (const entry of this.entries.values())
374
+ if (!this.isPinned(entry))
375
+ live += 1;
376
+ return live;
377
+ }
378
+ lruVictim(keepKey) {
379
+ let victim = null;
380
+ for (const entry of this.entries.values()) {
381
+ if (entry.key === keepKey || entry.inFlight || this.isPinned(entry))
382
+ continue;
383
+ if (victim === null || entry.lastActiveAt < victim.lastActiveAt)
384
+ victim = entry;
385
+ }
386
+ return victim;
387
+ }
388
+ }