@pinet/broker-core 0.2.4 → 0.2.7

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,1096 @@
1
+ import * as crypto from "node:crypto";
2
+ import { evaluateHibernateEligibility } from "./lifecycle.js";
3
+ import { sanitizeCheckpointReasonCode, sanitizeOperatorReason } from "./hibernation-status.js";
4
+ export const DEFAULT_ORCHESTRATOR_CONFIG = {
5
+ handshakeTimeoutMs: 30_000,
6
+ wakeLeaseMs: 90_000,
7
+ hibernateLeaseMs: 120_000,
8
+ registrationTimeoutMs: 60_000,
9
+ maxWakeAttempts: 3,
10
+ maxConcurrentWakes: 2,
11
+ maxConcurrentWakesPerRepo: 1,
12
+ };
13
+ const HIBERNATABLE_PRIORITY = {
14
+ direct_a2a: 10,
15
+ slack_thread: 20,
16
+ scheduled: 30,
17
+ lane_assignment: 40,
18
+ manual: 15,
19
+ };
20
+ export function wakeTriggerPriority(kind) {
21
+ return HIBERNATABLE_PRIORITY[kind];
22
+ }
23
+ // agent-standards-ignore prefer-inline-single-use-helper: bounded fail-closed
24
+ // timeout is a real async control-flow seam; inlining the race + timer cleanup
25
+ // into hibernate() would obscure the checkpoint-safety path.
26
+ async function withTimeout(work, timeoutMs, onTimeout) {
27
+ let timer = null;
28
+ const timeout = new Promise((resolve) => {
29
+ timer = setTimeout(() => resolve(onTimeout()), timeoutMs);
30
+ timer.unref?.();
31
+ });
32
+ try {
33
+ return await Promise.race([work, timeout]);
34
+ }
35
+ finally {
36
+ if (timer)
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ /**
41
+ * Broker-managed hibernation lifecycle orchestrator.
42
+ *
43
+ * Composes the durable primitives in {@link BrokerDB} (fenced leases, CAS
44
+ * lifecycle transitions, runtime specs, checkpoint receipts, generation
45
+ * reservations, wake queue, telemetry events) with injected process/tmux
46
+ * adapters. Every path fails closed: on any anomaly it releases its lease and
47
+ * quarantines the agent as `reap-candidate` with an actionable reason rather
48
+ * than guessing, rerouting affinity work, or killing a PID on PID alone.
49
+ */
50
+ export class HibernationOrchestrator {
51
+ db;
52
+ process;
53
+ tmux;
54
+ brokerInstanceId;
55
+ config;
56
+ now;
57
+ newId;
58
+ awaitRuntimeRegistration;
59
+ constructor(deps) {
60
+ this.db = deps.db;
61
+ this.process = deps.process;
62
+ this.tmux = deps.tmux;
63
+ this.brokerInstanceId = deps.brokerInstanceId;
64
+ this.config = { ...DEFAULT_ORCHESTRATOR_CONFIG, ...(deps.config ?? {}) };
65
+ this.now = deps.now ?? (() => Date.now());
66
+ this.newId = deps.newId ?? (() => crypto.randomUUID());
67
+ this.awaitRuntimeRegistration =
68
+ deps.awaitRuntimeRegistration ?? ((ctx) => this.defaultAwaitRegistration(ctx));
69
+ }
70
+ // ─── Prepare (advance an eligible free agent toward `idle`) ─────
71
+ /**
72
+ * Advance an eligible, free, broker-managed agent through `grace` to `idle`
73
+ * so it becomes hibernation-ready. Idempotent and fenced by CAS. Used by both
74
+ * the manual `pinet hibernate` path and the auto scheduler. Never forces a
75
+ * working/unsafe/ineligible agent forward.
76
+ */
77
+ prepareHibernation(agentId, opts = {}) {
78
+ const reason = sanitizeOperatorReason(opts.reason) ?? "prepare_hibernation";
79
+ const actor = opts.actor ?? "broker";
80
+ const correlationId = opts.correlationId ?? this.newId();
81
+ let agent = this.db.getAgentById(agentId);
82
+ if (!agent)
83
+ return { ready: false, state: "live", reason: "unknown_agent" };
84
+ const runtimeSpec = this.db.getAgentRuntimeSpec(agentId);
85
+ if (runtimeSpec?.runtimeKind === "herdr") {
86
+ const unsupported = "hibernation unsupported on this runtime";
87
+ this.recordRefusal(agentId, "prepare_refused", unsupported, actor, correlationId);
88
+ return { ready: false, state: agent.lifecycleState ?? "live", reason: unsupported };
89
+ }
90
+ const eligibility = evaluateHibernateEligibility(agent);
91
+ if (!eligibility.eligible) {
92
+ this.recordRefusal(agentId, "prepare_refused", eligibility.reason, actor, correlationId);
93
+ return { ready: false, state: agent.lifecycleState ?? "live", reason: eligibility.reason };
94
+ }
95
+ // Legal path toward idle: live/active -> grace -> idle. Anything already at
96
+ // idle is ready; hibernating/hibernated/waking/terminated is not preparable.
97
+ const steps = [];
98
+ switch (agent.lifecycleState) {
99
+ case "idle":
100
+ return { ready: true, state: "idle", reason: "already_idle" };
101
+ case "live":
102
+ case "active":
103
+ steps.push("grace", "idle");
104
+ break;
105
+ case "grace":
106
+ steps.push("idle");
107
+ break;
108
+ default:
109
+ return {
110
+ ready: false,
111
+ state: agent.lifecycleState ?? "live",
112
+ reason: `not_preparable:${agent.lifecycleState}`,
113
+ };
114
+ }
115
+ for (const toState of steps) {
116
+ agent = this.db.transitionAgentLifecycle({
117
+ agentId,
118
+ expectedVersion: agent.lifecycleVersion ?? 0,
119
+ toState,
120
+ reason,
121
+ actor,
122
+ correlationId,
123
+ });
124
+ }
125
+ return { ready: true, state: agent.lifecycleState ?? "idle", reason: "ready" };
126
+ }
127
+ // ─── Hibernate ──────────────────────────────────────────────────
128
+ async hibernate(agentId, opts = {}) {
129
+ const correlationId = opts.correlationId ?? this.newId();
130
+ const actor = opts.actor ?? "broker";
131
+ // Operator-authored reason: bound + path-redact at the orchestrator boundary
132
+ // so it is safe regardless of caller, before it can be persisted into any
133
+ // lifecycle row / event.
134
+ const reason = sanitizeOperatorReason(opts.reason) ?? "manual";
135
+ const startedAt = this.now();
136
+ const agent = this.db.getAgentById(agentId);
137
+ if (!agent)
138
+ return this.refuseHibernate(agentId, correlationId, "live", "unknown_agent", actor);
139
+ const runtimeSpec = this.db.getAgentRuntimeSpec(agentId);
140
+ if (runtimeSpec?.runtimeKind === "herdr") {
141
+ return this.refuseHibernate(agentId, correlationId, agent.lifecycleState ?? "live", "hibernation unsupported on this runtime", actor);
142
+ }
143
+ const eligibility = evaluateHibernateEligibility(agent);
144
+ if (!eligibility.eligible) {
145
+ return this.refuseHibernate(agentId, correlationId, agent.lifecycleState ?? "live", eligibility.reason, actor);
146
+ }
147
+ if (agent.lifecycleState !== "idle") {
148
+ return this.refuseHibernate(agentId, correlationId, agent.lifecycleState ?? "live", `not_idle:${agent.lifecycleState}`, actor);
149
+ }
150
+ const spec = runtimeSpec;
151
+ if (!spec) {
152
+ return this.refuseHibernate(agentId, correlationId, "idle", "missing_runtime_spec", actor);
153
+ }
154
+ const lease = this.db.acquireAgentLifecycleLease({
155
+ agentId,
156
+ operation: "hibernate",
157
+ ownerBrokerInstanceId: this.brokerInstanceId,
158
+ leaseId: this.newId(),
159
+ ttlMs: this.config.hibernateLeaseMs,
160
+ now: this.now(),
161
+ });
162
+ if (!lease) {
163
+ return this.refuseHibernate(agentId, correlationId, "idle", "lease_contended", actor);
164
+ }
165
+ // Fail-closed fault tracking: an unexpected adapter/DB rejection must never
166
+ // leave the agent stranded in `hibernating`. `enteredHibernating` records
167
+ // that we own the CAS transition; `teardownStarted` records that the
168
+ // process stop was attempted (so the runtime's liveness is now unknown).
169
+ let versionCursor = agent.lifecycleVersion ?? 0;
170
+ let enteredHibernating = false;
171
+ let teardownStarted = false;
172
+ try {
173
+ // idle -> hibernating (fenced CAS)
174
+ const hibernating = this.transitionFenced(lease, {
175
+ agentId,
176
+ expectedVersion: versionCursor,
177
+ toState: "hibernating",
178
+ reason,
179
+ actor,
180
+ correlationId,
181
+ triggerSource: opts.trigger,
182
+ });
183
+ versionCursor = hibernating.lifecycleVersion ?? versionCursor + 1;
184
+ enteredHibernating = true;
185
+ // Cooperative checkpoint handshake (fail closed on timeout).
186
+ const checkpoint = await withTimeout(this.process.requestCheckpoint(spec), this.config.handshakeTimeoutMs, () => ({
187
+ hibernateSafe: false,
188
+ reason: "checkpoint_timeout",
189
+ sessionResumeRef: null,
190
+ pendingInboxCount: 0,
191
+ rssBytes: null,
192
+ }));
193
+ this.db.recordAgentCheckpointReceipt({
194
+ agentId,
195
+ runtimeGeneration: agent.runtimeGeneration ?? 0,
196
+ correlationId,
197
+ hibernateSafe: checkpoint.hibernateSafe,
198
+ // Persist only an allowlisted machine code, never the runtime's raw
199
+ // reason prose — the receipt is durable and read on recovery.
200
+ reason: sanitizeCheckpointReasonCode(checkpoint.reason),
201
+ sessionResumeRef: checkpoint.sessionResumeRef,
202
+ pendingInboxCount: checkpoint.pendingInboxCount,
203
+ rssBytes: checkpoint.rssBytes,
204
+ });
205
+ // New work or an unsafe runtime must abort hibernation back to a safe
206
+ // live/active state — never exit the process.
207
+ const freshInbox = this.db.getUnreadInboxCount(agentId);
208
+ if (!checkpoint.hibernateSafe || freshInbox > 0 || checkpoint.pendingInboxCount > 0) {
209
+ // `checkpoint.reason` is runtime-authored; collapse it to an allowlisted
210
+ // machine code so no argv/env/path prose leaks through the abort surface.
211
+ const abortReason = !checkpoint.hibernateSafe
212
+ ? `checkpoint_unsafe:${sanitizeCheckpointReasonCode(checkpoint.reason)}`
213
+ : "work_arrived_during_checkpoint";
214
+ // Rollback-to-active before teardown is a fail-closed SAFETY transition:
215
+ // use an unfenced administrative CAS so a hibernate lease that expired
216
+ // during a slow checkpoint handshake cannot leave the row stranded in
217
+ // `hibernating`. The version CAS still blocks clobbering a concurrent
218
+ // legitimate writer.
219
+ const active = this.transitionAdministrative({
220
+ agentId,
221
+ expectedVersion: versionCursor,
222
+ toState: "active",
223
+ reason: abortReason,
224
+ actor,
225
+ correlationId,
226
+ });
227
+ return {
228
+ ok: false,
229
+ agentId,
230
+ correlationId,
231
+ state: active.lifecycleState ?? "active",
232
+ reason: abortReason,
233
+ };
234
+ }
235
+ // Graceful teardown. Past this point the runtime has been asked to stop,
236
+ // so any subsequent fault leaves its liveness unknown → quarantine.
237
+ teardownStarted = true;
238
+ const stop = await this.process.stopRuntime(spec);
239
+ const stillAlive = await this.process.isRuntimeAlive(spec);
240
+ if (!stop.stopped || stillAlive) {
241
+ return this.quarantine(agentId, versionCursor, correlationId, actor, "runtime_survived_stop", lease);
242
+ }
243
+ const attachable = await this.tmux.isSessionAttachable(spec);
244
+ if (!attachable) {
245
+ return this.quarantine(agentId, versionCursor, correlationId, actor, "tmux_session_missing", lease);
246
+ }
247
+ // hibernating -> hibernated.
248
+ const durationMs = this.now() - startedAt;
249
+ const hibernated = this.transitionFenced(lease, {
250
+ agentId,
251
+ expectedVersion: versionCursor,
252
+ toState: "hibernated",
253
+ reason,
254
+ actor,
255
+ correlationId,
256
+ durationMs,
257
+ rssBytesBefore: checkpoint.rssBytes,
258
+ rssBytesAfter: stop.rssBytes,
259
+ });
260
+ return {
261
+ ok: true,
262
+ agentId,
263
+ correlationId,
264
+ state: hibernated.lifecycleState ?? "hibernated",
265
+ reason: "hibernated",
266
+ rssBytesBefore: checkpoint.rssBytes,
267
+ rssBytesAfter: stop.rssBytes,
268
+ durationMs,
269
+ };
270
+ }
271
+ catch {
272
+ // Redaction-by-construction: never surface the raw error (it can carry
273
+ // paths). Use a static fault code and fail closed based on how far we got.
274
+ const faultReason = "hibernate_fault";
275
+ if (!enteredHibernating) {
276
+ // State was never changed by us; surface a refusal without forcing a
277
+ // transition that might not be valid from the current state.
278
+ this.recordRefusal(agentId, "hibernate_refused", faultReason, actor, correlationId);
279
+ return {
280
+ ok: false,
281
+ agentId,
282
+ correlationId,
283
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "idle",
284
+ reason: faultReason,
285
+ };
286
+ }
287
+ try {
288
+ if (!teardownStarted) {
289
+ // The runtime was never asked to stop, so it is still alive: abort
290
+ // back to active rather than quarantine. Unfenced administrative CAS
291
+ // so an expired hibernate lease cannot strand the row in `hibernating`.
292
+ const active = this.transitionAdministrative({
293
+ agentId,
294
+ expectedVersion: versionCursor,
295
+ toState: "active",
296
+ reason: faultReason,
297
+ actor,
298
+ correlationId,
299
+ });
300
+ return {
301
+ ok: false,
302
+ agentId,
303
+ correlationId,
304
+ state: active.lifecycleState ?? "active",
305
+ reason: faultReason,
306
+ };
307
+ }
308
+ // Teardown began; runtime liveness is unknown → quarantine for review.
309
+ return this.quarantine(agentId, versionCursor, correlationId, actor, faultReason, lease);
310
+ }
311
+ catch {
312
+ // Even the recovery transition failed (e.g. version raced). Report a
313
+ // safe failure; the lease still releases in `finally`.
314
+ return {
315
+ ok: false,
316
+ agentId,
317
+ correlationId,
318
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "hibernating",
319
+ reason: faultReason,
320
+ };
321
+ }
322
+ }
323
+ finally {
324
+ this.db.releaseAgentLifecycleLease(agentId, lease.leaseId, lease.fenceToken);
325
+ }
326
+ }
327
+ // ─── Wake ───────────────────────────────────────────────────────
328
+ /**
329
+ * Cold-wake a hibernated agent as a single accepted runtime generation, then
330
+ * transition to `live` so the durable inbox drains in order. Concurrent
331
+ * triggers contend on the fenced wake lease; only one wins.
332
+ */
333
+ async wake(agentId, opts = {}) {
334
+ const correlationId = opts.correlationId ?? this.newId();
335
+ const actor = opts.actor ?? "broker";
336
+ // Bound + path-redact the operator-authored reason before it can be
337
+ // persisted into any lifecycle row / event.
338
+ const reason = sanitizeOperatorReason(opts.reason) ?? "manual";
339
+ const startedAt = this.now();
340
+ // Cross-cutting fault state, read by the outer catch:
341
+ // - `acceptedGeneration`: the socket layer atomically accepted our generation,
342
+ // so the runtime is live+connected. Acceptance is IRREVERSIBLE — a later
343
+ // fault must NEVER quarantine it; leave `waking` for `recoverStrandedWakes`.
344
+ // - `inFlightHandle`/`inFlightLaunched`: a launched-but-unaccepted attempt that
345
+ // may still be running. Any escape must prove-stop it (via its attempt-bound
346
+ // handle) or fail closed as `wake_ambiguous_launch` rather than leak it.
347
+ // - `inFlightReservedGeneration`/`inFlightReservationNonce`: the in-flight
348
+ // attempt's reservation identity, so the outer catch can settle it against
349
+ // the acceptance boundary (`finalizeWakeAttempt`) before prove-stopping.
350
+ let acceptedGeneration = false;
351
+ let inFlightLaunched = false;
352
+ let inFlightHandle = null;
353
+ let inFlightReservedGeneration = null;
354
+ let inFlightReservationNonce = null;
355
+ const agent = this.db.getAgentById(agentId);
356
+ if (!agent)
357
+ return this.refuseWake(agentId, correlationId, "live", "unknown_agent", actor);
358
+ if (agent.lifecycleState !== "hibernated") {
359
+ return this.refuseWake(agentId, correlationId, agent.lifecycleState ?? "live", `not_hibernated:${agent.lifecycleState}`, actor);
360
+ }
361
+ const spec = this.db.getAgentRuntimeSpec(agentId);
362
+ if (!spec) {
363
+ return this.refuseWake(agentId, correlationId, "hibernated", "missing_runtime_spec", actor);
364
+ }
365
+ if (spec.runtimeKind !== "tmux") {
366
+ return this.refuseWake(agentId, correlationId, "hibernated", "hibernation unsupported on this runtime", actor);
367
+ }
368
+ const lease = this.db.acquireAgentLifecycleLease({
369
+ agentId,
370
+ operation: "wake",
371
+ ownerBrokerInstanceId: this.brokerInstanceId,
372
+ leaseId: this.newId(),
373
+ ttlMs: this.config.wakeLeaseMs,
374
+ now: this.now(),
375
+ });
376
+ if (!lease) {
377
+ // The fenced lifecycle lease is held by someone else. Only a *live wake*
378
+ // lease actually drains the inbox, so we must distinguish:
379
+ // - a matching unexpired wake lease → a real in-flight wake is the single
380
+ // winner and this trigger is satisfied by it (benign no-op);
381
+ // - any other held lease (e.g. a lingering/expired hibernate lease around
382
+ // a crash) → no wake is in flight, so this trigger must NOT be dropped.
383
+ // Report distinct, retryable contention so the dispatcher requeues it.
384
+ const held = this.db.getAgentLifecycleLease(agentId);
385
+ const wakeInFlight = held !== null && held.operation === "wake" && Date.parse(held.expiresAt) > this.now();
386
+ const contentionReason = wakeInFlight ? "wake_in_progress" : "wake_lease_contended";
387
+ return this.refuseWake(agentId, correlationId, "hibernated", contentionReason, actor);
388
+ }
389
+ let versionCursor = agent.lifecycleVersion ?? 0;
390
+ try {
391
+ // hibernated -> waking (fenced CAS).
392
+ const waking = this.transitionFenced(lease, {
393
+ agentId,
394
+ expectedVersion: versionCursor,
395
+ toState: "waking",
396
+ reason,
397
+ actor,
398
+ correlationId,
399
+ triggerSource: opts.trigger,
400
+ });
401
+ versionCursor = waking.lifecycleVersion ?? versionCursor + 1;
402
+ const maxAttempts = Math.max(1, this.config.maxWakeAttempts);
403
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
404
+ // Keep the lease valid across this attempt's launch + registration waits
405
+ // so a legitimately long wake (whose cumulative waits can outrun a single
406
+ // lease TTL) still completes its fenced `live` transition instead of
407
+ // being quarantined on expiry. Renewal preserves the fence, so revival
408
+ // fencing is unchanged. A null result means ownership was lost to another
409
+ // broker → fail closed (quarantine) rather than double-drive the wake.
410
+ const renewed = this.db.renewAgentLifecycleLease({
411
+ agentId,
412
+ leaseId: lease.leaseId,
413
+ fenceToken: lease.fenceToken,
414
+ ttlMs: this.config.wakeLeaseMs,
415
+ now: this.now(),
416
+ });
417
+ if (!renewed) {
418
+ return this.quarantineWake(agentId, versionCursor, correlationId, actor, "wake_lease_lost", lease, attempt - 1);
419
+ }
420
+ const reservation = this.db.reserveWakeGeneration({
421
+ agentId,
422
+ wakeLeaseId: lease.leaseId,
423
+ fenceToken: lease.fenceToken,
424
+ correlationId,
425
+ now: this.now(),
426
+ });
427
+ const launchCtx = {
428
+ agentId,
429
+ stableId: spec.stableId,
430
+ wakeLeaseId: lease.leaseId,
431
+ fenceToken: lease.fenceToken,
432
+ reservedGeneration: reservation.reservedGeneration,
433
+ reservationNonce: reservation.reservationNonce,
434
+ correlationId,
435
+ spec,
436
+ };
437
+ inFlightReservedGeneration = reservation.reservedGeneration;
438
+ inFlightReservationNonce = reservation.reservationNonce;
439
+ // Launch the replacement runtime. A throw MID-LAUNCH leaves an unknown
440
+ // process with no handle to address, so its liveness is unprovable. But
441
+ // the socket layer accepts a generation ATOMICALLY, so even a launch that
442
+ // threw may have registered+accepted in the race window — so we still
443
+ // settle TRANSACTIONALLY against the acceptance boundary before failing
444
+ // closed. `accepted` ⇒ the runtime is live, promote it. `unknown` ⇒ the
445
+ // settle did not commit, so an acceptance may still race — leave `waking`
446
+ // for recovery rather than quarantine. Only `fenced-unaccepted` (a
447
+ // committed consumption that makes acceptance impossible) quarantines
448
+ // (`wake_ambiguous_launch`, since the throwing launch left no handle).
449
+ inFlightLaunched = true;
450
+ inFlightHandle = null;
451
+ let launch;
452
+ try {
453
+ launch = await this.tmux.respawnRuntime(launchCtx);
454
+ }
455
+ catch {
456
+ const settled = this.settleWakeAttempt(agentId, reservation);
457
+ if (settled === "accepted") {
458
+ acceptedGeneration = true;
459
+ inFlightLaunched = false;
460
+ inFlightHandle = null;
461
+ return this.promoteAcceptedWake({
462
+ agentId,
463
+ versionCursor,
464
+ reason,
465
+ actor,
466
+ correlationId,
467
+ reservedGeneration: reservation.reservedGeneration,
468
+ attempt,
469
+ startedAt,
470
+ });
471
+ }
472
+ if (settled === "unknown") {
473
+ return {
474
+ ok: true,
475
+ agentId,
476
+ correlationId,
477
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "waking",
478
+ reason: "woken_recovery_pending",
479
+ };
480
+ }
481
+ return this.quarantineWake(agentId, versionCursor, correlationId, actor, "wake_ambiguous_launch", lease, attempt);
482
+ }
483
+ inFlightLaunched = launch.launched;
484
+ inFlightHandle = launch.handle;
485
+ // Wait for the woken runtime to re-register and present its fence. The
486
+ // socket layer accepts the generation ATOMICALLY on a valid registration,
487
+ // so acceptance can happen even if the wait throws afterward — we swallow
488
+ // it and let the race-free settle below decide.
489
+ try {
490
+ if (launch.launched)
491
+ await this.awaitRuntimeRegistration(launchCtx);
492
+ }
493
+ catch {
494
+ // Ignore: `settleWakeAttempt` is the authoritative, race-free classifier.
495
+ }
496
+ // AUTHORITATIVE, RACE-FREE acceptance classification. The socket accepts a
497
+ // generation atomically, so acceptance can land between our last waiter
498
+ // read and now. `finalizeWakeAttempt` runs in one transaction: if our
499
+ // generation was accepted it returns accepted; otherwise it consumes THIS
500
+ // attempt's exact-nonce reservation so the launched runtime can never be
501
+ // accepted afterwards, making the subsequent prove-stop safe.
502
+ const settled = this.settleWakeAttempt(agentId, reservation);
503
+ if (settled === "accepted") {
504
+ // The socket layer already bound this runtime to our exact
505
+ // lease/fence/reservation and atomically advanced+consumed the
506
+ // generation, so from here acceptance is IRREVERSIBLE: the runtime is
507
+ // live and connected and must NEVER be quarantined.
508
+ acceptedGeneration = true;
509
+ inFlightLaunched = false; // the attempt IS the live runtime; never stop it
510
+ inFlightHandle = null;
511
+ return this.promoteAcceptedWake({
512
+ agentId,
513
+ versionCursor,
514
+ reason,
515
+ actor,
516
+ correlationId,
517
+ reservedGeneration: reservation.reservedGeneration,
518
+ attempt,
519
+ startedAt,
520
+ });
521
+ }
522
+ if (settled === "unknown") {
523
+ // The atomic settle did NOT commit, so THIS attempt's reservation was
524
+ // not provably consumed and an acceptance can still race any stop. We
525
+ // must NOT prove-stop or retry the attempt (doing so could kill a
526
+ // runtime that accepts a moment later). Leave the identity `waking`
527
+ // (never quarantine) and hand it to `recoverStrandedWakes`, which
528
+ // reconciles it once the transactional settle can commit.
529
+ return {
530
+ ok: true,
531
+ agentId,
532
+ correlationId,
533
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "waking",
534
+ reason: "woken_recovery_pending",
535
+ };
536
+ }
537
+ // Failed attempt (`fenced-unaccepted`). The reservation is now
538
+ // transactionally consumed (settle above), so the
539
+ // launched runtime can never be accepted. But a launched-but-unaccepted
540
+ // process may still be RUNNING, and we must never leave one behind
541
+ // (whether we are about to relaunch on top of it OR about to quarantine
542
+ // and hand the durable row to a spec-addressed reaper that cannot see this
543
+ // PID), so we best-effort stop it and only proceed if we can PROVE it is
544
+ // gone; otherwise fail closed (`wake_ambiguous_launch`). Running on the
545
+ // final attempt too closes the same leak symmetrically.
546
+ //
547
+ // The stop/liveness proof is addressed by the attempt-bound handle from
548
+ // THIS launch (`launch.handle`), not the durable spec. Using the spec
549
+ // would target the pre-hibernation runtime's recorded PID generation —
550
+ // which is already dead — and so would always "confirm stopped" while the
551
+ // newly launched attempt kept running. A launch that produced no handle,
552
+ // or a stop/liveness probe that throws, is unprovable and must fail closed
553
+ // (`proveAttemptStopped` swallows throws and returns false).
554
+ if (launch.launched && !(await this.proveAttemptStopped(launch.handle))) {
555
+ return this.quarantineWake(agentId, versionCursor, correlationId, actor, "wake_ambiguous_launch", lease, attempt);
556
+ }
557
+ inFlightLaunched = false; // this attempt is confirmed gone (or nothing launched)
558
+ inFlightHandle = null;
559
+ inFlightReservedGeneration = null;
560
+ inFlightReservationNonce = null;
561
+ this.db.recordAgentLifecycleEvent({
562
+ agentId,
563
+ fromState: "waking",
564
+ toState: "waking",
565
+ lifecycleVersion: versionCursor,
566
+ reason,
567
+ actor,
568
+ correlationId,
569
+ outcome: attempt < maxAttempts ? "wake_retry" : "wake_exhausted",
570
+ errorCode: "launch_or_registration_not_accepted",
571
+ fenceToken: lease.fenceToken,
572
+ });
573
+ }
574
+ // All attempts exhausted → quarantine.
575
+ return this.quarantineWake(agentId, versionCursor, correlationId, actor, "wake_attempts_exhausted", lease, maxAttempts);
576
+ }
577
+ catch {
578
+ // Fail-closed on an unexpected adapter/DB fault that escaped the per-attempt
579
+ // handling. Two invariants override the generic quarantine:
580
+ //
581
+ // 1. If our generation was already ACCEPTED, the runtime is live and
582
+ // socket-bound. Acceptance is irreversible: never quarantine it — leave
583
+ // `waking` for `recoverStrandedWakes` to promote to `live`.
584
+ if (acceptedGeneration) {
585
+ return {
586
+ ok: true,
587
+ agentId,
588
+ correlationId,
589
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "waking",
590
+ reason: "woken_recovery_pending",
591
+ };
592
+ }
593
+ // 2. A launched-but-unaccepted attempt may still be running AND may have
594
+ // raced to accept before this fault. Settle it against the acceptance
595
+ // boundary TRANSACTIONALLY first: only `fenced-unaccepted` (a committed
596
+ // reservation consumption) makes a prove-stop safe. `accepted` means it
597
+ // is live — never quarantine it (leave `waking` for recovery). `unknown`
598
+ // means the settle did not commit, so the reservation was not provably
599
+ // consumed and an acceptance can still race a stop — we must NOT
600
+ // prove-stop/quarantine either; leave `waking` for reconciliation.
601
+ // Static reasons only; never surface raw errors.
602
+ if (inFlightLaunched &&
603
+ inFlightReservedGeneration !== null &&
604
+ inFlightReservationNonce !== null) {
605
+ const settled = this.settleWakeAttempt(agentId, {
606
+ reservedGeneration: inFlightReservedGeneration,
607
+ reservationNonce: inFlightReservationNonce,
608
+ });
609
+ if (settled === "accepted" || settled === "unknown") {
610
+ return {
611
+ ok: true,
612
+ agentId,
613
+ correlationId,
614
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "waking",
615
+ reason: "woken_recovery_pending",
616
+ };
617
+ }
618
+ // settled === "fenced-unaccepted": reservation transactionally consumed →
619
+ // the launched runtime can no longer be accepted, so the prove-stop below
620
+ // is safe.
621
+ }
622
+ const faultReason = inFlightLaunched && !(await this.proveAttemptStopped(inFlightHandle))
623
+ ? "wake_ambiguous_launch"
624
+ : "wake_fault";
625
+ try {
626
+ return this.quarantineWake(agentId, versionCursor, correlationId, actor, faultReason, lease, 0);
627
+ }
628
+ catch {
629
+ return {
630
+ ok: false,
631
+ agentId,
632
+ correlationId,
633
+ state: this.db.getAgentById(agentId)?.lifecycleState ?? "waking",
634
+ reason: faultReason,
635
+ };
636
+ }
637
+ }
638
+ finally {
639
+ this.db.releaseAgentLifecycleLease(agentId, lease.leaseId, lease.fenceToken);
640
+ }
641
+ }
642
+ /**
643
+ * Best-effort proof that a launched wake ATTEMPT's runtime is gone, addressed
644
+ * by its attempt-bound handle (never the durable spec). Fail-closed: a missing
645
+ * handle, an unconfirmed stop, a still-alive probe, OR any adapter throw all
646
+ * count as "not proven gone", so the caller quarantines (`wake_ambiguous_launch`)
647
+ * rather than relaunch on / strand a possibly-live runtime.
648
+ */
649
+ async proveAttemptStopped(handle) {
650
+ if (!handle)
651
+ return false;
652
+ try {
653
+ const stop = await this.process.stopLaunchedAttempt(handle);
654
+ if (!stop.stopped)
655
+ return false;
656
+ return !(await this.process.isLaunchedAttemptAlive(handle));
657
+ }
658
+ catch {
659
+ return false;
660
+ }
661
+ }
662
+ /**
663
+ * Race-free, TRI-STATE settle of a wake attempt against the acceptance
664
+ * boundary. Delegates to the transactional {@link BrokerDB.finalizeWakeAttempt}:
665
+ *
666
+ * - `"accepted"` — the socket already accepted our generation. The runtime is
667
+ * live+bound; it must be promoted, NEVER stopped.
668
+ * - `"fenced-unaccepted"` — the settle transaction COMMITTED a consumption of
669
+ * THIS attempt's exact-nonce reservation, so the launched runtime can never
670
+ * be accepted afterwards. Only now is a subsequent prove-stop safe.
671
+ * - `"unknown"` — the settle transaction threw and did NOT commit, so the
672
+ * reservation was NOT provably consumed. An acceptance may still race an
673
+ * async prove-stop, so the caller must NOT stop or retry the attempt; it
674
+ * leaves the identity `waking` for `recoverStrandedWakes` to reconcile once
675
+ * the atomic settle can commit. A best-effort read here would be unsafe:
676
+ * reading "not accepted" does not prevent a concurrent acceptance, so we
677
+ * must never downgrade `unknown` to `fenced-unaccepted`.
678
+ */
679
+ // agent-standards-ignore prefer-inline-single-use-helper: shared by the
680
+ // per-attempt path and the outer fault backstop; a real acceptance-boundary seam.
681
+ settleWakeAttempt(agentId, reservation) {
682
+ try {
683
+ const result = this.db.finalizeWakeAttempt({
684
+ agentId,
685
+ reservedGeneration: reservation.reservedGeneration,
686
+ reservationNonce: reservation.reservationNonce,
687
+ });
688
+ return result.accepted ? "accepted" : "fenced-unaccepted";
689
+ }
690
+ catch {
691
+ return "unknown";
692
+ }
693
+ }
694
+ /**
695
+ * Finalize an ACCEPTED wake to `live`. Acceptance is irreversible: the socket
696
+ * bound this runtime to our exact lease/fence/reservation and atomically
697
+ * advanced+consumed the generation, so the runtime is live+connected. The
698
+ * `waking -> live` promotion is pure bookkeeping — driven with an unfenced
699
+ * administrative CAS (a lease that expired *after* acceptance must not throw a
700
+ * fenced transition and quarantine an already-live runtime) and GUARDED so any
701
+ * post-acceptance DB fault (transition, inbox count, wake completion) leaves the
702
+ * identity in `waking` (`woken_recovery_pending`) for `recoverStrandedWakes` to
703
+ * finish to `live`, rather than quarantining a live worker.
704
+ */
705
+ // agent-standards-ignore prefer-inline-single-use-helper: shared by the
706
+ // per-attempt accepted path and the respawn-throw accepted path.
707
+ promoteAcceptedWake(params) {
708
+ const durationMs = this.now() - params.startedAt;
709
+ try {
710
+ const live = this.transitionAdministrative({
711
+ agentId: params.agentId,
712
+ expectedVersion: params.versionCursor,
713
+ toState: "live",
714
+ reason: params.reason,
715
+ actor: params.actor,
716
+ correlationId: params.correlationId,
717
+ durationMs,
718
+ queueDepth: this.db.getUnreadInboxCount(params.agentId),
719
+ });
720
+ this.db.completeWakeForAgent(params.agentId);
721
+ return {
722
+ ok: true,
723
+ agentId: params.agentId,
724
+ correlationId: params.correlationId,
725
+ state: live.lifecycleState ?? "live",
726
+ reason: "woken",
727
+ runtimeGeneration: params.reservedGeneration,
728
+ attempts: params.attempt,
729
+ durationMs,
730
+ };
731
+ }
732
+ catch {
733
+ return {
734
+ ok: true,
735
+ agentId: params.agentId,
736
+ correlationId: params.correlationId,
737
+ state: this.db.getAgentById(params.agentId)?.lifecycleState ?? "waking",
738
+ reason: "woken_recovery_pending",
739
+ runtimeGeneration: params.reservedGeneration,
740
+ attempts: params.attempt,
741
+ };
742
+ }
743
+ }
744
+ /**
745
+ * Attempt to accept a launched runtime's generation on registration. Called
746
+ * by the socket server (or the injected registration waiter). Idempotent and
747
+ * fenced: only the reservation's exact lease/fence/generation is accepted.
748
+ */
749
+ acceptRuntimeRegistration(input) {
750
+ const acceptance = this.db.acceptRuntimeGeneration({ ...input, now: this.now() });
751
+ if (!acceptance.accepted) {
752
+ const agent = this.db.getAgentById(input.agentId);
753
+ this.db.recordAgentLifecycleEvent({
754
+ agentId: input.agentId,
755
+ fromState: agent?.lifecycleState ?? "waking",
756
+ toState: agent?.lifecycleState ?? "waking",
757
+ lifecycleVersion: agent?.lifecycleVersion ?? 0,
758
+ reason: "runtime_registration",
759
+ actor: "broker",
760
+ correlationId: input.wakeLeaseId,
761
+ outcome: "generation_rejected",
762
+ errorCode: acceptance.reason,
763
+ fenceToken: input.fenceToken,
764
+ });
765
+ }
766
+ return acceptance;
767
+ }
768
+ /**
769
+ * Reconcile lifecycle + wake-queue state left inconsistent by a broker crash.
770
+ * Intended to run once on broker startup (and is safe to re-run). DB-only and
771
+ * idempotent. Three classes of strand are repaired:
772
+ *
773
+ * 1. Agents in `waking` (crash between generation acceptance and the final
774
+ * `waking -> live` transition):
775
+ * - If a runtime already accepted its generation (reservation consumed and
776
+ * runtime_generation advanced past the checkpoint's generation) only the
777
+ * final live transition was lost → complete to `live` so the inbox drains.
778
+ * - Otherwise the wake outcome is uncertain (a runtime may or may not have
779
+ * launched) → fail closed to `reap-candidate` for manual review.
780
+ * 2. Agents in `hibernating` (crash mid-hibernate, before reaching the durable
781
+ * `hibernated` state): the runtime may or may not have been torn down, so
782
+ * completing to `hibernated` risks a double launch on the next wake → fail
783
+ * closed to `reap-candidate` for manual review.
784
+ * 3. Wake-queue rows left in `dispatching` (crash mid-dispatch): the owning
785
+ * dispatch loop is gone, so return them to `queued` (they also block the
786
+ * unique active-agent index until reclaimed) so a fresh pass re-dispatches.
787
+ *
788
+ * Only a lease held by THIS live broker instance causes a skip (we are still
789
+ * actively driving that operation). A lease owned by a *different* instance is
790
+ * orphaned from a prior, now-dead broker — a crash normally leaves precisely
791
+ * such an unexpired-but-orphaned lease — so it is reconciled immediately rather
792
+ * than waiting out its TTL (during which the row would otherwise be stranded).
793
+ */
794
+ recoverStrandedWakes(opts = {}) {
795
+ const now = opts.now ?? this.now();
796
+ const recovered = [];
797
+ for (const agent of this.db.getAllAgents()) {
798
+ const strandedState = agent.lifecycleState;
799
+ if (strandedState !== "waking" && strandedState !== "hibernating")
800
+ continue;
801
+ const lease = this.db.getAgentLifecycleLease(agent.id);
802
+ const heldByThisBroker = lease !== null &&
803
+ Date.parse(lease.expiresAt) > now &&
804
+ lease.ownerBrokerInstanceId === this.brokerInstanceId;
805
+ if (heldByThisBroker)
806
+ continue;
807
+ const version = agent.lifecycleVersion ?? 0;
808
+ const correlationId = this.newId();
809
+ // A stranded `hibernating` agent never reached the durable `hibernated`
810
+ // state; its runtime liveness is unknown, so fail closed rather than risk
811
+ // a double launch by completing the hibernate.
812
+ if (strandedState === "hibernating") {
813
+ try {
814
+ this.db.cancelWake(agent.id);
815
+ this.db.clearAgentWakeReservation(agent.id);
816
+ this.db.transitionAgentLifecycle({
817
+ agentId: agent.id,
818
+ expectedVersion: version,
819
+ toState: "reap-candidate",
820
+ reason: "hibernate_recovery_stranded",
821
+ actor: "broker",
822
+ correlationId,
823
+ });
824
+ if (lease)
825
+ this.db.releaseAgentLifecycleLease(agent.id, lease.leaseId, lease.fenceToken);
826
+ recovered.push({ agentId: agent.id, action: "quarantined" });
827
+ }
828
+ catch {
829
+ // Raced with a live owner or a concurrent recovery pass; leave it be.
830
+ }
831
+ continue;
832
+ }
833
+ const reservation = this.db.getAgentWakeReservation(agent.id);
834
+ const checkpointGeneration = this.db.getLatestAgentCheckpointReceipt(agent.id)?.runtimeGeneration ?? null;
835
+ const currentGeneration = agent.runtimeGeneration ?? 0;
836
+ const generationAccepted = reservation === null &&
837
+ checkpointGeneration !== null &&
838
+ currentGeneration > checkpointGeneration;
839
+ try {
840
+ if (generationAccepted) {
841
+ this.db.transitionAgentLifecycle({
842
+ agentId: agent.id,
843
+ expectedVersion: version,
844
+ toState: "live",
845
+ reason: "wake_recovery_complete",
846
+ actor: "broker",
847
+ correlationId,
848
+ });
849
+ this.db.completeWakeForAgent(agent.id);
850
+ if (lease)
851
+ this.db.releaseAgentLifecycleLease(agent.id, lease.leaseId, lease.fenceToken);
852
+ recovered.push({ agentId: agent.id, action: "completed" });
853
+ }
854
+ else {
855
+ this.db.clearAgentWakeReservation(agent.id);
856
+ this.db.cancelWake(agent.id);
857
+ this.db.transitionAgentLifecycle({
858
+ agentId: agent.id,
859
+ expectedVersion: version,
860
+ toState: "reap-candidate",
861
+ reason: "wake_recovery_stranded",
862
+ actor: "broker",
863
+ correlationId,
864
+ });
865
+ if (lease)
866
+ this.db.releaseAgentLifecycleLease(agent.id, lease.leaseId, lease.fenceToken);
867
+ recovered.push({ agentId: agent.id, action: "quarantined" });
868
+ }
869
+ }
870
+ catch {
871
+ // Raced with a live owner or a concurrent recovery pass; leave it be.
872
+ }
873
+ }
874
+ // Reclaim wake-queue rows orphaned mid-dispatch by the crash. On a fresh
875
+ // startup nothing is actively dispatching, so any `dispatching` row is
876
+ // stale; return it to `queued` for a fresh dispatch pass.
877
+ for (const row of this.db.listWakeQueue("dispatching")) {
878
+ const requeued = this.db.requeueWake(row.id);
879
+ if (requeued)
880
+ recovered.push({ agentId: row.agentId, action: "requeued" });
881
+ }
882
+ return recovered;
883
+ }
884
+ // ─── Wake queue dispatch ────────────────────────────────────────
885
+ /**
886
+ * Enqueue a wake trigger for a hibernated agent. Idempotent per agent and
887
+ * priority-ordered (targeted work first). Never fans out to broadcast.
888
+ */
889
+ enqueueWakeTrigger(input) {
890
+ const spec = this.db.getAgentRuntimeSpec(input.agentId);
891
+ this.db.enqueueWake({
892
+ agentId: input.agentId,
893
+ repoRoot: spec?.repoRoot ?? null,
894
+ triggerKind: input.triggerKind,
895
+ triggerMessageId: input.triggerMessageId ?? null,
896
+ priority: wakeTriggerPriority(input.triggerKind),
897
+ reason: input.reason,
898
+ correlationId: input.correlationId ?? this.newId(),
899
+ });
900
+ }
901
+ /**
902
+ * Dispatch queued wakes respecting global and per-repo concurrency limits,
903
+ * in priority then oldest-first order. Returns the results of wakes started
904
+ * this pass. Safe to call repeatedly (e.g. on a timer or after each trigger).
905
+ */
906
+ async dispatchWakeQueue() {
907
+ const results = [];
908
+ // Agents whose wake could not start this pass because a *non-wake* lease is
909
+ // transiently held. Their rows are requeued (not consumed); skip them for the
910
+ // remainder of this pass so a lingering lease cannot spin the loop.
911
+ const deferred = new Set();
912
+ for (;;) {
913
+ const globalInflight = this.db.countInflightWakes();
914
+ if (globalInflight >= this.config.maxConcurrentWakes)
915
+ break;
916
+ const next = this.selectNextDispatchableWake(deferred);
917
+ if (!next)
918
+ break;
919
+ const claimed = this.db.markWakeDispatching(next.id);
920
+ if (!claimed)
921
+ continue;
922
+ let result;
923
+ try {
924
+ result = await this.wake(claimed.agentId, {
925
+ trigger: claimed.triggerKind,
926
+ reason: claimed.reason,
927
+ correlationId: claimed.correlationId,
928
+ });
929
+ }
930
+ catch {
931
+ // wake() is designed to fail closed without throwing, but a dispatching
932
+ // queue row must never strand the drain pass. Guard the finalization
933
+ // write ITSELF: if cancelling the row also throws (transient DB fault),
934
+ // leave the row `dispatching` — it carries no held lease and is requeued
935
+ // by `recoverStrandedWakes` on the next reconciliation pass — rather than
936
+ // letting the exception crash the loop and strand every other queued row.
937
+ let lifecycleState = "waking";
938
+ try {
939
+ this.db.completeWakeQueueEntry(claimed.id, "cancelled");
940
+ lifecycleState = this.db.getAgentById(claimed.agentId)?.lifecycleState ?? "waking";
941
+ }
942
+ catch {
943
+ // Finalization write failed; the row stays reclaimable via reconciliation.
944
+ }
945
+ results.push({
946
+ ok: false,
947
+ agentId: claimed.agentId,
948
+ correlationId: claimed.correlationId,
949
+ state: lifecycleState,
950
+ reason: "wake_fault",
951
+ });
952
+ continue;
953
+ }
954
+ try {
955
+ if (!result.ok && result.reason === "wake_in_progress") {
956
+ // A live wake lease owner is already draining this agent; consume the row.
957
+ this.db.completeWakeQueueEntry(claimed.id, "done");
958
+ }
959
+ else if (!result.ok && result.reason === "wake_lease_contended") {
960
+ // A non-wake lifecycle lease is transiently holding the agent; no wake is
961
+ // in flight, so the trigger must survive. Requeue and defer this agent so
962
+ // the same lease cannot re-select it and spin this pass.
963
+ this.db.requeueWake(claimed.id);
964
+ deferred.add(claimed.agentId);
965
+ }
966
+ else {
967
+ this.db.completeWakeQueueEntry(claimed.id, result.ok ? "done" : "cancelled");
968
+ }
969
+ }
970
+ catch {
971
+ // A transient finalization-write failure must not crash the drain pass or
972
+ // strand the other queued agents. The wake itself already resolved durably
973
+ // above; the row simply stays `dispatching` and is reclaimed by
974
+ // `recoverStrandedWakes` (which requeues every `dispatching` row) on the
975
+ // next startup/reconciliation pass. Fail-closed: never lose the trigger.
976
+ }
977
+ results.push(result);
978
+ }
979
+ return results;
980
+ }
981
+ selectNextDispatchableWake(deferred) {
982
+ const queued = this.db.listWakeQueue("queued");
983
+ for (const entry of queued) {
984
+ if (deferred?.has(entry.agentId))
985
+ continue;
986
+ const repoInflight = this.db.countInflightWakes(entry.repoRoot ?? null);
987
+ if (repoInflight >= this.config.maxConcurrentWakesPerRepo)
988
+ continue;
989
+ return entry;
990
+ }
991
+ return null;
992
+ }
993
+ // ─── Internal helpers ───────────────────────────────────────────
994
+ async defaultAwaitRegistration(ctx) {
995
+ const deadline = this.now() + this.config.registrationTimeoutMs;
996
+ for (;;) {
997
+ const agent = this.db.getAgentById(ctx.agentId);
998
+ if (agent?.runtimeGeneration === ctx.reservedGeneration)
999
+ return true;
1000
+ if (this.now() >= deadline)
1001
+ return false;
1002
+ await new Promise((resolve) => setTimeout(resolve, 25));
1003
+ }
1004
+ }
1005
+ refuseHibernate(agentId, correlationId, state, reason, actor) {
1006
+ this.recordRefusal(agentId, "hibernate_refused", reason, actor, correlationId);
1007
+ return { ok: false, agentId, correlationId, state, reason };
1008
+ }
1009
+ refuseWake(agentId, correlationId, state, reason, actor) {
1010
+ this.recordRefusal(agentId, "wake_refused", reason, actor, correlationId);
1011
+ return { ok: false, agentId, correlationId, state, reason };
1012
+ }
1013
+ recordRefusal(agentId, outcome, reason, actor, correlationId) {
1014
+ const agent = this.db.getAgentById(agentId);
1015
+ this.db.recordAgentLifecycleEvent({
1016
+ agentId,
1017
+ fromState: agent?.lifecycleState ?? "live",
1018
+ toState: agent?.lifecycleState ?? "live",
1019
+ lifecycleVersion: agent?.lifecycleVersion ?? 0,
1020
+ reason,
1021
+ actor,
1022
+ correlationId,
1023
+ outcome,
1024
+ });
1025
+ }
1026
+ /**
1027
+ * Drive a fenced lifecycle transition bound to the *live* held lease. Passing
1028
+ * the full lease identity (fence + id + operation + current time) lets the DB
1029
+ * reject an expired, superseded, or wrong-operation lease rather than trusting
1030
+ * the fence token alone. `now` is read fresh per call so a lease that expires
1031
+ * mid-operation cannot authorize a later transition.
1032
+ */
1033
+ transitionFenced(lease, input) {
1034
+ return this.db.transitionAgentLifecycle({
1035
+ ...input,
1036
+ fenceToken: lease.fenceToken,
1037
+ leaseId: lease.leaseId,
1038
+ expectedOperation: lease.operation,
1039
+ now: this.now(),
1040
+ });
1041
+ }
1042
+ /**
1043
+ * Fail-closed *safety* transition to a quarantine/abort state. Unlike a
1044
+ * forward-progress transition this is deliberately UNFENCED: it must be able
1045
+ * to fire even when our own lease has expired mid-operation (e.g. a wake whose
1046
+ * cumulative adapter waits outran the lease TTL), otherwise the agent would be
1047
+ * stranded in `waking`/`hibernating`. Safety is preserved by the version CAS
1048
+ * inside `transitionAgentLifecycle`: if another broker legitimately advanced
1049
+ * the agent (bumping the version) our recovery CAS fails and we do not clobber
1050
+ * it; if nobody else touched it, we move it to the safe state.
1051
+ */
1052
+ transitionAdministrative(input) {
1053
+ return this.db.transitionAgentLifecycle(input);
1054
+ }
1055
+ quarantine(agentId, expectedVersion, correlationId, actor, reason, _lease) {
1056
+ const current = this.transitionAdministrative({
1057
+ agentId,
1058
+ expectedVersion,
1059
+ toState: "reap-candidate",
1060
+ reason,
1061
+ actor,
1062
+ correlationId,
1063
+ });
1064
+ return {
1065
+ ok: false,
1066
+ agentId,
1067
+ correlationId,
1068
+ state: current.lifecycleState ?? "reap-candidate",
1069
+ reason,
1070
+ };
1071
+ }
1072
+ quarantineWake(agentId, expectedVersion, correlationId, actor, reason, _lease, attempts) {
1073
+ this.db.clearAgentWakeReservation(agentId);
1074
+ const current = this.transitionAdministrative({
1075
+ agentId,
1076
+ expectedVersion,
1077
+ toState: "reap-candidate",
1078
+ reason,
1079
+ actor,
1080
+ correlationId,
1081
+ });
1082
+ return {
1083
+ ok: false,
1084
+ agentId,
1085
+ correlationId,
1086
+ state: current.lifecycleState ?? "reap-candidate",
1087
+ reason,
1088
+ attempts,
1089
+ };
1090
+ }
1091
+ }
1092
+ /** Convenience: derive whether an agent is a durable hibernation identity. */
1093
+ export function isDurableHibernationState(agent) {
1094
+ const state = agent.lifecycleState;
1095
+ return state === "hibernating" || state === "hibernated" || state === "waking";
1096
+ }