@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.
- package/dist/agent-messaging.d.ts +27 -4
- package/dist/hibernation-commands.d.ts +123 -0
- package/dist/hibernation-commands.js +287 -0
- package/dist/hibernation-orchestrator.d.ts +327 -0
- package/dist/hibernation-orchestrator.js +1096 -0
- package/dist/hibernation-projection.d.ts +20 -0
- package/dist/hibernation-projection.js +60 -0
- package/dist/hibernation-status.d.ts +141 -0
- package/dist/hibernation-status.js +390 -0
- package/dist/hibernation-telemetry.d.ts +54 -0
- package/dist/hibernation-telemetry.js +119 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/leader.d.ts +134 -5
- package/dist/leader.js +359 -26
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +59 -0
- package/dist/mail-classification.d.ts +6 -1
- package/dist/message-send.d.ts +4 -3
- package/dist/router.d.ts +14 -1
- package/dist/router.js +15 -0
- package/dist/schema.d.ts +181 -2
- package/dist/schema.js +1243 -17
- package/dist/types.d.ts +288 -1
- package/dist/types.js +6 -0
- package/package.json +5 -5
|
@@ -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
|
+
}
|