@pinet/broker-core 0.2.4 → 0.2.6

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.
@@ -0,0 +1,327 @@
1
+ import type { BrokerDB } from "./schema.js";
2
+ import type { AgentInfo, RuntimeGenerationAcceptance, TmuxAgentRuntimeSpec, WakeTriggerKind } from "./types.js";
3
+ /**
4
+ * Result of asking a follower to cooperatively checkpoint before exit. When
5
+ * `hibernateSafe` is false the orchestrator aborts hibernation and keeps the
6
+ * runtime alive rather than exiting an unsafe process.
7
+ */
8
+ export interface HibernationCheckpointOutcome {
9
+ hibernateSafe: boolean;
10
+ reason: string | null;
11
+ sessionResumeRef: string | null;
12
+ pendingInboxCount: number;
13
+ rssBytes: number | null;
14
+ }
15
+ /** Everything a launch adapter needs to bring back exactly the fenced runtime. */
16
+ export interface RuntimeLaunchContext {
17
+ agentId: string;
18
+ stableId: string;
19
+ wakeLeaseId: string;
20
+ fenceToken: number;
21
+ reservedGeneration: number;
22
+ /** Per-attempt nonce the launched runtime must echo back on registration. */
23
+ reservationNonce: string;
24
+ correlationId: string;
25
+ spec: TmuxAgentRuntimeSpec;
26
+ }
27
+ /**
28
+ * Opaque, attempt-bound identity for a runtime a single wake attempt launched.
29
+ * Returned by {@link HibernationTmuxController.respawnRuntime} and required by
30
+ * the attempt-scoped stop/liveness checks so that retry cleanup proves the
31
+ * EXACT process THIS attempt spawned is gone — never the pre-hibernation runtime
32
+ * (whose recorded PID generation is already dead) nor a different attempt.
33
+ */
34
+ export interface RuntimeAttemptHandle {
35
+ /** The reservation nonce of the launching attempt (binds the handle to it). */
36
+ readonly reservationNonce: string;
37
+ /** The tmux pane the attempt launched into (a real adapter locates the PID). */
38
+ readonly tmuxTarget: string;
39
+ /** OS pid of the launched runtime, if the adapter captured it. */
40
+ readonly pid: number | null;
41
+ }
42
+ /** Controls the dormant Pi runtime process (never tmux itself). */
43
+ export interface HibernationProcessController {
44
+ /** Ask the follower to flush a checkpoint and confirm hibernate safety. */
45
+ requestCheckpoint(spec: TmuxAgentRuntimeSpec): Promise<HibernationCheckpointOutcome>;
46
+ /** Gracefully stop the live Pi runtime (TERM then bounded KILL) before hibernation. */
47
+ stopRuntime(spec: TmuxAgentRuntimeSpec): Promise<{
48
+ stopped: boolean;
49
+ rssBytes: number | null;
50
+ }>;
51
+ /** True while the recorded Pi PID/process generation is still alive. */
52
+ isRuntimeAlive(spec: TmuxAgentRuntimeSpec): Promise<boolean>;
53
+ /**
54
+ * Stop the runtime a SPECIFIC wake attempt launched, addressed by its
55
+ * attempt-bound handle (not the durable spec). Used by retry cleanup to prove
56
+ * the exact failed-attempt process is gone before relaunching.
57
+ */
58
+ stopLaunchedAttempt(handle: RuntimeAttemptHandle): Promise<{
59
+ stopped: boolean;
60
+ }>;
61
+ /** True while the runtime a SPECIFIC wake attempt launched is still alive. */
62
+ isLaunchedAttemptAlive(handle: RuntimeAttemptHandle): Promise<boolean>;
63
+ }
64
+ /** Reads/writes the attachable tmux shell that outlives the Pi runtime. */
65
+ export interface HibernationTmuxController {
66
+ /** True if the recorded tmux session/pane still exists (operator-attachable). */
67
+ isSessionAttachable(spec: TmuxAgentRuntimeSpec): Promise<boolean>;
68
+ /**
69
+ * Launch exactly one replacement runtime into the recorded pane. The launched
70
+ * runtime is expected to register presenting the reservation fence. Resolves
71
+ * once the launch command has been issued (registration is confirmed
72
+ * separately via {@link BrokerDB.acceptRuntimeGeneration}). On a successful
73
+ * launch it returns an attempt-bound {@link RuntimeAttemptHandle} so retry
74
+ * cleanup can prove THIS attempt's runtime is stopped before relaunching; a
75
+ * launch that yields no handle is treated as unprovable (fail closed).
76
+ */
77
+ respawnRuntime(ctx: RuntimeLaunchContext): Promise<{
78
+ launched: boolean;
79
+ handle: RuntimeAttemptHandle | null;
80
+ }>;
81
+ }
82
+ export interface HibernationOrchestratorConfig {
83
+ /** Max time to wait for the cooperative checkpoint handshake. */
84
+ handshakeTimeoutMs: number;
85
+ /** Lease TTL for a wake operation. */
86
+ wakeLeaseMs: number;
87
+ /** Lease TTL for a hibernate operation. */
88
+ hibernateLeaseMs: number;
89
+ /** Time to wait for a launched runtime to register + accept its generation. */
90
+ registrationTimeoutMs: number;
91
+ /** Max wake attempts before quarantining as reap-candidate. */
92
+ maxWakeAttempts: number;
93
+ /** Global concurrent in-flight wakes. */
94
+ maxConcurrentWakes: number;
95
+ /** Per-repo concurrent in-flight wakes. */
96
+ maxConcurrentWakesPerRepo: number;
97
+ }
98
+ export declare const DEFAULT_ORCHESTRATOR_CONFIG: HibernationOrchestratorConfig;
99
+ export interface HibernationOrchestratorDeps {
100
+ db: BrokerDB;
101
+ process: HibernationProcessController;
102
+ tmux: HibernationTmuxController;
103
+ brokerInstanceId: string;
104
+ config?: Partial<HibernationOrchestratorConfig>;
105
+ /** Injectable clock (ms). Defaults to Date.now. */
106
+ now?: () => number;
107
+ /** Injectable id generator. Defaults to crypto.randomUUID. */
108
+ newId?: () => string;
109
+ /**
110
+ * Confirm the launched runtime registered and its generation was accepted.
111
+ * Resolves true once accepted, false on timeout. Injected so tests can drive
112
+ * registration deterministically; the runtime wires this to the socket server.
113
+ */
114
+ awaitRuntimeRegistration?: (ctx: RuntimeLaunchContext) => Promise<boolean>;
115
+ }
116
+ export interface HibernateResult {
117
+ ok: boolean;
118
+ agentId: string;
119
+ correlationId: string;
120
+ state: string;
121
+ reason: string;
122
+ rssBytesBefore?: number | null;
123
+ rssBytesAfter?: number | null;
124
+ durationMs?: number;
125
+ }
126
+ export interface WakeResult {
127
+ ok: boolean;
128
+ agentId: string;
129
+ correlationId: string;
130
+ state: string;
131
+ reason: string;
132
+ runtimeGeneration?: number;
133
+ attempts?: number;
134
+ durationMs?: number;
135
+ }
136
+ /** Outcome of reconciling one agent/queue-row stranded by a broker crash. */
137
+ export interface StrandedWakeRecovery {
138
+ /** Agent id, or (for `requeued`) the orphaned dispatch row's agent id. */
139
+ agentId: string;
140
+ /**
141
+ * - `completed` — a stranded `waking` whose generation was already accepted; only the
142
+ * final live transition was lost, so it was finished to `live`.
143
+ * - `quarantined` — a stranded `waking` or `hibernating` with an uncertain runtime; moved
144
+ * to `reap-candidate` for manual review rather than risk a double launch.
145
+ * - `requeued` — a `dispatching` wake-queue row orphaned mid-dispatch by a crash;
146
+ * returned to `queued` so a fresh dispatch pass can pick it up.
147
+ */
148
+ action: "completed" | "quarantined" | "requeued";
149
+ }
150
+ export declare function wakeTriggerPriority(kind: WakeTriggerKind): number;
151
+ /**
152
+ * Broker-managed hibernation lifecycle orchestrator.
153
+ *
154
+ * Composes the durable primitives in {@link BrokerDB} (fenced leases, CAS
155
+ * lifecycle transitions, runtime specs, checkpoint receipts, generation
156
+ * reservations, wake queue, telemetry events) with injected process/tmux
157
+ * adapters. Every path fails closed: on any anomaly it releases its lease and
158
+ * quarantines the agent as `reap-candidate` with an actionable reason rather
159
+ * than guessing, rerouting affinity work, or killing a PID on PID alone.
160
+ */
161
+ export declare class HibernationOrchestrator {
162
+ private readonly db;
163
+ private readonly process;
164
+ private readonly tmux;
165
+ private readonly brokerInstanceId;
166
+ private readonly config;
167
+ private readonly now;
168
+ private readonly newId;
169
+ private readonly awaitRuntimeRegistration;
170
+ constructor(deps: HibernationOrchestratorDeps);
171
+ /**
172
+ * Advance an eligible, free, broker-managed agent through `grace` to `idle`
173
+ * so it becomes hibernation-ready. Idempotent and fenced by CAS. Used by both
174
+ * the manual `pinet hibernate` path and the auto scheduler. Never forces a
175
+ * working/unsafe/ineligible agent forward.
176
+ */
177
+ prepareHibernation(agentId: string, opts?: {
178
+ reason?: string;
179
+ actor?: string;
180
+ correlationId?: string;
181
+ }): {
182
+ ready: boolean;
183
+ state: string;
184
+ reason: string;
185
+ };
186
+ hibernate(agentId: string, opts?: {
187
+ reason?: string;
188
+ actor?: string;
189
+ trigger?: string;
190
+ correlationId?: string;
191
+ }): Promise<HibernateResult>;
192
+ /**
193
+ * Cold-wake a hibernated agent as a single accepted runtime generation, then
194
+ * transition to `live` so the durable inbox drains in order. Concurrent
195
+ * triggers contend on the fenced wake lease; only one wins.
196
+ */
197
+ wake(agentId: string, opts?: {
198
+ reason?: string;
199
+ actor?: string;
200
+ trigger?: WakeTriggerKind;
201
+ correlationId?: string;
202
+ }): Promise<WakeResult>;
203
+ /**
204
+ * Best-effort proof that a launched wake ATTEMPT's runtime is gone, addressed
205
+ * by its attempt-bound handle (never the durable spec). Fail-closed: a missing
206
+ * handle, an unconfirmed stop, a still-alive probe, OR any adapter throw all
207
+ * count as "not proven gone", so the caller quarantines (`wake_ambiguous_launch`)
208
+ * rather than relaunch on / strand a possibly-live runtime.
209
+ */
210
+ private proveAttemptStopped;
211
+ /**
212
+ * Race-free, TRI-STATE settle of a wake attempt against the acceptance
213
+ * boundary. Delegates to the transactional {@link BrokerDB.finalizeWakeAttempt}:
214
+ *
215
+ * - `"accepted"` — the socket already accepted our generation. The runtime is
216
+ * live+bound; it must be promoted, NEVER stopped.
217
+ * - `"fenced-unaccepted"` — the settle transaction COMMITTED a consumption of
218
+ * THIS attempt's exact-nonce reservation, so the launched runtime can never
219
+ * be accepted afterwards. Only now is a subsequent prove-stop safe.
220
+ * - `"unknown"` — the settle transaction threw and did NOT commit, so the
221
+ * reservation was NOT provably consumed. An acceptance may still race an
222
+ * async prove-stop, so the caller must NOT stop or retry the attempt; it
223
+ * leaves the identity `waking` for `recoverStrandedWakes` to reconcile once
224
+ * the atomic settle can commit. A best-effort read here would be unsafe:
225
+ * reading "not accepted" does not prevent a concurrent acceptance, so we
226
+ * must never downgrade `unknown` to `fenced-unaccepted`.
227
+ */
228
+ private settleWakeAttempt;
229
+ /**
230
+ * Finalize an ACCEPTED wake to `live`. Acceptance is irreversible: the socket
231
+ * bound this runtime to our exact lease/fence/reservation and atomically
232
+ * advanced+consumed the generation, so the runtime is live+connected. The
233
+ * `waking -> live` promotion is pure bookkeeping — driven with an unfenced
234
+ * administrative CAS (a lease that expired *after* acceptance must not throw a
235
+ * fenced transition and quarantine an already-live runtime) and GUARDED so any
236
+ * post-acceptance DB fault (transition, inbox count, wake completion) leaves the
237
+ * identity in `waking` (`woken_recovery_pending`) for `recoverStrandedWakes` to
238
+ * finish to `live`, rather than quarantining a live worker.
239
+ */
240
+ private promoteAcceptedWake;
241
+ /**
242
+ * Attempt to accept a launched runtime's generation on registration. Called
243
+ * by the socket server (or the injected registration waiter). Idempotent and
244
+ * fenced: only the reservation's exact lease/fence/generation is accepted.
245
+ */
246
+ acceptRuntimeRegistration(input: {
247
+ agentId: string;
248
+ wakeLeaseId: string;
249
+ fenceToken: number;
250
+ reservedGeneration: number;
251
+ reservationNonce: string;
252
+ }): RuntimeGenerationAcceptance;
253
+ /**
254
+ * Reconcile lifecycle + wake-queue state left inconsistent by a broker crash.
255
+ * Intended to run once on broker startup (and is safe to re-run). DB-only and
256
+ * idempotent. Three classes of strand are repaired:
257
+ *
258
+ * 1. Agents in `waking` (crash between generation acceptance and the final
259
+ * `waking -> live` transition):
260
+ * - If a runtime already accepted its generation (reservation consumed and
261
+ * runtime_generation advanced past the checkpoint's generation) only the
262
+ * final live transition was lost → complete to `live` so the inbox drains.
263
+ * - Otherwise the wake outcome is uncertain (a runtime may or may not have
264
+ * launched) → fail closed to `reap-candidate` for manual review.
265
+ * 2. Agents in `hibernating` (crash mid-hibernate, before reaching the durable
266
+ * `hibernated` state): the runtime may or may not have been torn down, so
267
+ * completing to `hibernated` risks a double launch on the next wake → fail
268
+ * closed to `reap-candidate` for manual review.
269
+ * 3. Wake-queue rows left in `dispatching` (crash mid-dispatch): the owning
270
+ * dispatch loop is gone, so return them to `queued` (they also block the
271
+ * unique active-agent index until reclaimed) so a fresh pass re-dispatches.
272
+ *
273
+ * Only a lease held by THIS live broker instance causes a skip (we are still
274
+ * actively driving that operation). A lease owned by a *different* instance is
275
+ * orphaned from a prior, now-dead broker — a crash normally leaves precisely
276
+ * such an unexpired-but-orphaned lease — so it is reconciled immediately rather
277
+ * than waiting out its TTL (during which the row would otherwise be stranded).
278
+ */
279
+ recoverStrandedWakes(opts?: {
280
+ now?: number;
281
+ }): StrandedWakeRecovery[];
282
+ /**
283
+ * Enqueue a wake trigger for a hibernated agent. Idempotent per agent and
284
+ * priority-ordered (targeted work first). Never fans out to broadcast.
285
+ */
286
+ enqueueWakeTrigger(input: {
287
+ agentId: string;
288
+ triggerKind: WakeTriggerKind;
289
+ reason: string;
290
+ triggerMessageId?: number | null;
291
+ correlationId?: string;
292
+ }): void;
293
+ /**
294
+ * Dispatch queued wakes respecting global and per-repo concurrency limits,
295
+ * in priority then oldest-first order. Returns the results of wakes started
296
+ * this pass. Safe to call repeatedly (e.g. on a timer or after each trigger).
297
+ */
298
+ dispatchWakeQueue(): Promise<WakeResult[]>;
299
+ private selectNextDispatchableWake;
300
+ private defaultAwaitRegistration;
301
+ private refuseHibernate;
302
+ private refuseWake;
303
+ private recordRefusal;
304
+ /**
305
+ * Drive a fenced lifecycle transition bound to the *live* held lease. Passing
306
+ * the full lease identity (fence + id + operation + current time) lets the DB
307
+ * reject an expired, superseded, or wrong-operation lease rather than trusting
308
+ * the fence token alone. `now` is read fresh per call so a lease that expires
309
+ * mid-operation cannot authorize a later transition.
310
+ */
311
+ private transitionFenced;
312
+ /**
313
+ * Fail-closed *safety* transition to a quarantine/abort state. Unlike a
314
+ * forward-progress transition this is deliberately UNFENCED: it must be able
315
+ * to fire even when our own lease has expired mid-operation (e.g. a wake whose
316
+ * cumulative adapter waits outran the lease TTL), otherwise the agent would be
317
+ * stranded in `waking`/`hibernating`. Safety is preserved by the version CAS
318
+ * inside `transitionAgentLifecycle`: if another broker legitimately advanced
319
+ * the agent (bumping the version) our recovery CAS fails and we do not clobber
320
+ * it; if nobody else touched it, we move it to the safe state.
321
+ */
322
+ private transitionAdministrative;
323
+ private quarantine;
324
+ private quarantineWake;
325
+ }
326
+ /** Convenience: derive whether an agent is a durable hibernation identity. */
327
+ export declare function isDurableHibernationState(agent: AgentInfo): boolean;