@staix/agent-hub 0.6.3 → 0.7.0

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/src/hub/bus.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { DIGEST, HUB, MAX_HOP, newEnvelope, parseMarker, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter } from "./peers.ts";
3
+ import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
+
5
+ export interface DeliveryReceipt {
6
+ id: string;
7
+ state: "accepted" | "completed" | "failed_safe" | "needs_review";
8
+ reason?: string;
9
+ }
3
10
 
4
11
  export type BusEvent =
5
12
  | { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
@@ -16,6 +23,7 @@ export interface BusOptions {
16
23
  queueCap: number;
17
24
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
18
25
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
26
+ journal?: DeliveryJournal;
19
27
  }
20
28
 
21
29
  /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
@@ -26,6 +34,8 @@ export interface BusSnapshot {
26
34
  seen: Envelope[];
27
35
  attempts: Record<string, number>;
28
36
  withdrawn: string[];
37
+ manualPaused?: string[];
38
+ journal?: ReturnType<DeliveryJournal["snapshot"]>;
29
39
  }
30
40
 
31
41
  export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
@@ -63,9 +73,11 @@ const DIGEST_MAX = 10;
63
73
  export class Bus {
64
74
  readonly peers = new Map<PeerId, PeerAdapter>();
65
75
  private readonly opts: BusOptions;
76
+ private readonly journal?: DeliveryJournal;
66
77
  private readonly queues = new Map<PeerId, Envelope[]>();
67
78
  private readonly prefaces = new Map<PeerId, Envelope>();
68
79
  private readonly paused = new Set<PeerId>();
80
+ private readonly manualPaused = new Set<PeerId>();
69
81
  private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
70
82
  private readonly draining = new Set<PeerId>();
71
83
  private readonly seen = new Map<string, Envelope>();
@@ -74,17 +86,41 @@ export class Bus {
74
86
  /** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
75
87
  private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
76
88
  private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
89
+ /** Correlates each native receipt independently; a peer can have a steer and a delivery in flight. */
90
+ private readonly activeDeliveries = new Map<string, PeerId>();
91
+ private readonly pendingOutcomes: Omit<JournalDelivery, "revision" | "updatedAt">[] = [];
92
+ private suppressDrain = false;
93
+ private readonly recoveryHeldPeers = new Set<PeerId>();
94
+ /**
95
+ * The queue lengths changed. `publish` emits its envelope event *before* it enqueues, and a delivery emits
96
+ * nothing at all, so anything that renders `queued` off a bus event records the count from before the change
97
+ * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
98
+ */
99
+ onQueues?: () => void;
77
100
  private recoveryHeld = false;
78
101
  private steering = 0;
79
102
  private condensing = 0;
103
+ private closed = false;
104
+ storageError?: string;
80
105
 
81
106
  constructor(opts: Partial<BusOptions> = {}) {
82
107
  this.opts = { ...DEFAULT_BUS, ...opts };
108
+ this.journal = opts.journal;
109
+ if (this.journal) {
110
+ const state = this.journal.snapshot();
111
+ const bus = state.bus as BusSnapshot;
112
+ if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
113
+ for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
114
+ for (const row of this.journal.list()) {
115
+ if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
116
+ }
117
+ }
83
118
  }
84
119
 
85
120
  add(peer: PeerAdapter): void {
86
121
  this.peers.set(peer.id, peer);
87
122
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
123
+ peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
88
124
  peer.onMessage = (text, opts) => {
89
125
  const { priority, body } = parseMarker(text);
90
126
  if (!body) return;
@@ -103,14 +139,51 @@ export class Bus {
103
139
  // The adapter got the condensed list; what has to come back is what that list replaced.
104
140
  const last = this.lastDelivery.get(peer.id);
105
141
  const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
106
- this.failed(peer.id, same ? last.originals : envs);
142
+ if (this.journal) {
143
+ for (const [deliveryId, owner] of this.activeDeliveries) {
144
+ if (owner !== peer.id) continue;
145
+ const row = this.journal.get(deliveryId);
146
+ if (row && row.originals.some((e) => envs.some((incoming) => incoming.id === e.id))) {
147
+ this.journal.transition(deliveryId, "needs_review", "adapter reported delivery failure without safe-failure evidence");
148
+ this.activeDeliveries.delete(deliveryId);
149
+ }
150
+ }
151
+ } else this.failed(peer.id, same ? last.originals : envs);
107
152
  };
108
153
  peer.onState = () => {
154
+ if (this.closed) return;
155
+ if (this.peers.get(peer.id) !== peer) return;
156
+ if (peer.state === "offline" && this.journal) {
157
+ for (const [id, owner] of [...this.activeDeliveries]) if (owner === peer.id) this.uncertain(peer.id, id, "peer disconnected before delivery settlement");
158
+ }
109
159
  this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
110
160
  void this.drain(peer.id);
111
161
  };
112
162
  }
113
163
 
164
+ private persist(): void {
165
+ if (!this.journal) return;
166
+ try {
167
+ this.journal.transaction(() => {
168
+ for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
169
+ this.journal!.persistBus(this.snapshotWithoutJournal(), [...this.manualPaused]);
170
+ });
171
+ this.pendingOutcomes.length = 0;
172
+ this.storageError = undefined;
173
+ }
174
+ catch { this.storageError = "delivery journal unavailable"; throw new Error("delivery journal unavailable"); }
175
+ }
176
+
177
+ private durableHandoff(peer: PeerId, deliveryId: string, originals: Envelope[], out: Envelope[]): void {
178
+ if (!this.journal) return;
179
+ const delivery = { id: deliveryId, peer, state: "dispatching" as const, createdAt: Date.now(), originals, out };
180
+ this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery);
181
+ }
182
+
183
+ private snapshotWithoutJournal(): BusSnapshot {
184
+ return this.snapshot(false);
185
+ }
186
+
114
187
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
115
188
  setRecoveryHold(held: boolean): void {
116
189
  this.recoveryHeld = held;
@@ -128,20 +201,35 @@ export class Bus {
128
201
  }
129
202
 
130
203
  /** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
131
- snapshot(): BusSnapshot {
132
- return {
204
+ snapshot(includeJournal = true): BusSnapshot {
205
+ const out: BusSnapshot = {
133
206
  schemaVersion: 1,
134
207
  queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
135
208
  prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
136
209
  seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
137
210
  attempts: Object.fromEntries(this.attempts),
138
211
  withdrawn: [...this.withdrawn],
212
+ manualPaused: [...this.manualPaused],
139
213
  };
214
+ if (includeJournal && this.journal) out.journal = this.journal.snapshot();
215
+ return out;
140
216
  }
141
217
 
142
218
  /** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
143
- restore(snapshot: BusSnapshot): void {
219
+ restore(snapshot: BusSnapshot, _operationId?: string): void {
144
220
  if (snapshot.schemaVersion !== 1) throw new Error("unsupported bus recovery snapshot");
221
+ if (_operationId && this.journal) {
222
+ const current = this.journal.snapshot();
223
+ if (current.revision === 0) {
224
+ this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId);
225
+ }
226
+ snapshot = this.journal.snapshot().bus;
227
+ }
228
+ this.loadSnapshot(snapshot);
229
+ this.persist();
230
+ }
231
+
232
+ private loadSnapshot(snapshot: BusSnapshot): void {
145
233
  this.queues.clear();
146
234
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
147
235
  this.prefaces.clear();
@@ -152,6 +240,122 @@ export class Bus {
152
240
  for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
153
241
  this.withdrawn.clear();
154
242
  for (const id of snapshot.withdrawn ?? []) this.withdrawn.add(id);
243
+ if (snapshot.manualPaused) { this.manualPaused.clear(); this.paused.clear(); for (const id of snapshot.manualPaused) { this.manualPaused.add(id); this.paused.add(id); } }
244
+ }
245
+
246
+ knownPeers(): string[] {
247
+ const pending = this.storageError ? [] : this.journal?.list().filter((row) => ["queued", "dispatching", "accepted", "needs_review"].includes(row.state)).map((row) => row.peer) ?? [];
248
+ return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
249
+ }
250
+
251
+ queueList(peer?: string): JournalDelivery[] {
252
+ if (this.journal) {
253
+ const rows = this.journal.list(peer).filter((row) => !row.reason?.startsWith("grouped into delivery "));
254
+ const revision = this.journal.snapshot().revision;
255
+ const recorded = new Set(rows.filter((r) => ["queued", "dispatching", "accepted", "needs_review"].includes(r.state)).flatMap((r) => (r.originals as Envelope[]).map((e) => `${r.peer}:${e.id}`)));
256
+ const ids = peer ? [peer] : [...this.queues.keys()];
257
+ for (const p of ids) for (const e of this.queues.get(p) ?? []) if (!recorded.has(`${p}:${e.id}`)) rows.push({ id: `q:${p}:${e.id}`, peer: p, state: "queued", revision, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] });
258
+ return rows.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
259
+ }
260
+ const ids = peer ? [peer] : [...this.queues.keys()];
261
+ return ids.flatMap((p) => (this.queues.get(p) ?? []).map((e) => ({ id: e.id, peer: p, state: "queued" as const, revision: 0, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] })));
262
+ }
263
+
264
+ queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
265
+
266
+ resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string): void {
267
+ if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
268
+ const record = this.queueShow(id);
269
+ if (!record) throw new Error("unknown delivery");
270
+ const previous = this.journal.resolution(id);
271
+ if (previous) {
272
+ this.journal.resolve(id, revision, action, reason); // verifies identical request; no second enqueue
273
+ return;
274
+ }
275
+ if (["dispatching", "accepted"].includes(record.state) || this.peers.get(record.peer)?.state === "busy") throw new Error("active delivery cannot be resolved");
276
+ if (record.revision !== revision) throw new Error("stale delivery revision");
277
+ if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
278
+ if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
279
+ const before = this.snapshotWithoutJournal();
280
+ try {
281
+ this.journal.transaction(() => {
282
+ if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
283
+ const current = this.journal!.get(id)!;
284
+ this.journal!.resolve(id, current.revision, action, reason, revision);
285
+ if (record.state === "queued") {
286
+ const ids = new Set(record.originals.map((env) => env.id));
287
+ this.queues.set(record.peer, (this.queues.get(record.peer) ?? []).filter((env) => !ids.has(env.id)));
288
+ }
289
+ if (action === "retry") {
290
+ const queue = this.queues.get(record.peer) ?? [];
291
+ for (const env of record.originals) {
292
+ if (env.from === HUB && env.kind === "presence") this.prefaces.set(record.peer, env);
293
+ else if (!queue.some((old) => old.id === env.id)) queue.push(env);
294
+ }
295
+ this.queues.set(record.peer, queue);
296
+ }
297
+ this.persist();
298
+ });
299
+ } catch (error) { this.loadSnapshot(before); throw error; }
300
+ this.refreshRecoveryHold(record.peer);
301
+ this.onQueues?.();
302
+ void this.drain(record.peer);
303
+ }
304
+
305
+ queueSummary(peer: string): { needsReview: number; oldestQueuedAt?: number } {
306
+ const rows = this.storageError ? [] : this.queueList(peer);
307
+ const queued = this.queues.get(peer) ?? [];
308
+ const needsReview = rows.filter((r) => r.state === "needs_review").length;
309
+ const oldest = queued.reduce<number | undefined>((a, r) => a === undefined ? r.ts : Math.min(a, r.ts), undefined);
310
+ return oldest === undefined ? { needsReview } : { needsReview, oldestQueuedAt: oldest };
311
+ }
312
+
313
+ manualPausedPeers(): string[] { return [...this.manualPaused].sort(); }
314
+ setManualPaused(ids: string[]): void { this.manualPaused.clear(); for (const id of ids) this.manualPaused.add(id); this.persist(); }
315
+ closeJournal(): void {
316
+ this.closed = true;
317
+ this.recoveryHeld = true;
318
+ for (const timer of this.timers.values()) clearTimeout(timer);
319
+ this.journal?.close();
320
+ }
321
+
322
+ deliveryReceipt(peer: PeerId, receipt: DeliveryReceipt): void {
323
+ if (this.closed) return;
324
+ const owner = this.activeDeliveries.get(receipt.id);
325
+ if (!owner || owner !== peer || !this.journal) return;
326
+ const id = receipt.id;
327
+ const before = this.journal.get(id);
328
+ const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
329
+ this.journal.transition(id, state, receipt.reason);
330
+ if (state === "failed") {
331
+ if (before) this.failed(peer, before.originals);
332
+ }
333
+ if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
334
+ if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
335
+ if (state === "needs_review") this.recoveryHeldPeers.add(peer);
336
+ this.onQueues?.();
337
+ }
338
+
339
+ private refreshRecoveryHold(peer: PeerId): void {
340
+ if (!this.journal) return;
341
+ const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || r.state === "dispatching" || r.state === "accepted");
342
+ if (blocked) this.recoveryHeldPeers.add(peer); else this.recoveryHeldPeers.delete(peer);
343
+ }
344
+
345
+ completeReply(peer: PeerId, envelopeId: string): void {
346
+ if (!this.journal) return;
347
+ for (const [id, owner] of this.activeDeliveries) {
348
+ if (owner !== peer) continue;
349
+ const row = this.journal.get(id);
350
+ if (row?.out.some((e) => e.id === envelopeId)) {
351
+ this.journal.transition(id, "completed", "correlated reply");
352
+ this.activeDeliveries.delete(id);
353
+ this.refreshRecoveryHold(peer);
354
+ this.onQueues?.();
355
+ void this.drain(peer);
356
+ break;
357
+ }
358
+ }
155
359
  }
156
360
 
157
361
  queueIds(id: PeerId): string[] {
@@ -175,11 +379,13 @@ export class Bus {
175
379
 
176
380
  pause(id: PeerId): void {
177
381
  this.paused.add(id);
382
+ this.persist();
178
383
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
179
384
  }
180
385
 
181
386
  resume(id: PeerId): void {
182
387
  if (!this.paused.delete(id)) return;
388
+ this.persist();
183
389
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
184
390
  void this.drain(id);
185
391
  }
@@ -191,13 +397,16 @@ export class Bus {
191
397
  // Recovery and session recall may both contribute context. Keep the original id/hop so a queued
192
398
  // preface remains deduplicated and append the new, separately generated context.
193
399
  this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
400
+ this.persist();
194
401
  return;
195
402
  }
196
403
  this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
404
+ this.persist();
197
405
  }
198
406
 
199
407
  /** Returns the peers the envelope was queued for. */
200
408
  publish(env: Envelope): PeerId[] {
409
+ if (this.storageError) throw new Error("delivery journal unavailable");
201
410
  if (this.seen.has(env.id)) return [];
202
411
  this.seen.set(env.id, env);
203
412
  if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
@@ -205,16 +414,36 @@ export class Bus {
205
414
  // fyi is for the record (console now, task board from M4), never worth an agent's turn.
206
415
  const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
207
416
  this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
208
- if (dropped) return [];
209
-
210
- const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
211
- for (const id of targets) {
212
- const peer = this.peers.get(id)!;
213
- if (!this.recoveryHeld && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
417
+ if (dropped) { this.persist(); return []; }
418
+
419
+ const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
420
+ const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && known.has(id));
421
+ this.suppressDrain = !!this.journal;
422
+ try { for (const id of targets) {
423
+ const peer = this.peers.get(id);
424
+ if (!peer) { this.enqueue(id, env); continue; }
425
+ if (!this.journal && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
214
426
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
215
427
  this.steering++;
216
- peer.steer([env]).catch(() => this.enqueue(id, env, true)).finally(() => { this.steering--; });
428
+ const deliveryId = crypto.randomUUID();
429
+ this.activeDeliveries.set(deliveryId, id);
430
+ if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
431
+ const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
432
+ steerResult.then(() => {}).catch(() => {
433
+ if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
434
+ this.activeDeliveries.delete(deliveryId);
435
+ if (!this.journal) this.enqueue(id, env, true);
436
+ }).finally(() => { this.steering--; });
217
437
  } else this.enqueue(id, env);
438
+ } } finally { this.suppressDrain = false; }
439
+ if (this.journal) {
440
+ this.persist();
441
+ this.onQueues?.();
442
+ for (const id of targets) {
443
+ const peer = this.peers.get(id);
444
+ if (!this.recoveryHeld && !this.recoveryHeldPeers.has(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
445
+ else void this.drain(id);
446
+ }
218
447
  }
219
448
  return targets;
220
449
  }
@@ -237,6 +466,8 @@ export class Bus {
237
466
  const i = queue.findIndex((e) => e.id === envelopeId);
238
467
  if (i !== -1) removed = queue.splice(i, 1).length > 0;
239
468
  }
469
+ if (removed) this.onQueues?.();
470
+ this.persist();
240
471
  return removed;
241
472
  }
242
473
 
@@ -244,17 +475,30 @@ export class Bus {
244
475
  return this.queues.get(id)?.length ?? 0;
245
476
  }
246
477
 
478
+ /**
479
+ * Queued envelopes that skip the batch window (issue #41: `queued 3` hid which ones the peer still waits out).
480
+ * An `important` envelope that is in a queue is one that could not be steered, so it does not interrupt a
481
+ * running turn; it is why the queue is delivered the moment the peer goes idle.
482
+ */
483
+ queuedImportant(id: PeerId): number {
484
+ return (this.queues.get(id) ?? []).filter((e) => e.priority === "important").length;
485
+ }
486
+
247
487
  private enqueue(id: PeerId, env: Envelope, front = false): void {
248
488
  if (this.withdrawn.has(env.id)) return;
249
- const queue = this.queues.get(id)!;
489
+ const queue = this.queues.get(id) ?? [];
490
+ this.queues.set(id, queue);
250
491
  if (front) queue.unshift(env);
251
492
  else queue.push(env);
252
493
  if (queue.length > this.opts.queueCap) {
253
494
  const victim = queue.findIndex((e) => e.priority !== "important");
254
495
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
496
+ if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
255
497
  this.emit({ t: "overflow", env: lost!, peer: id });
256
498
  }
257
- void this.drain(id);
499
+ this.onQueues?.();
500
+ if (!this.suppressDrain) this.persist();
501
+ if (!this.suppressDrain) void this.drain(id);
258
502
  }
259
503
 
260
504
  /** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
@@ -263,55 +507,80 @@ export class Bus {
263
507
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
264
508
  }
265
509
 
510
+ private uncertain(peer: PeerId, deliveryId: string, reason: string): void {
511
+ const row = this.journal?.get(deliveryId);
512
+ if (row && ["dispatching", "accepted"].includes(row.state)) this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", reason });
513
+ }
514
+
515
+ private async steerQueued(id: PeerId, env: Envelope): Promise<void> {
516
+ const peer = this.peers.get(id);
517
+ const queue = this.queues.get(id);
518
+ if (!this.journal || !peer?.steer || !queue || this.storageError) return;
519
+ const index = queue.findIndex((item) => item.id === env.id);
520
+ if (index < 0) return;
521
+ this.steering++;
522
+ const deliveryId = crypto.randomUUID();
523
+ try {
524
+ queue.splice(index, 1);
525
+ this.durableHandoff(id, deliveryId, [env], [env]);
526
+ this.activeDeliveries.set(deliveryId, id);
527
+ try { await peer.steer([env], deliveryId); }
528
+ catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
529
+ } catch { this.storageError = "delivery journal unavailable"; }
530
+ finally { this.steering--; this.onQueues?.(); }
531
+ }
532
+
266
533
  private async drain(id: PeerId): Promise<void> {
267
- if (this.draining.has(id)) return;
534
+ if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
268
535
  this.draining.add(id);
269
536
  try {
270
537
  const peer = this.peers.get(id)!;
271
538
  const queue = this.queues.get(id)!;
272
- while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
273
- const wait = this.wait(queue);
274
- if (wait > 0) {
275
- this.arm(id, wait);
276
- break;
277
- }
539
+ while (!this.storageError && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && queue.length && this.stateOf(id) === "idle") {
540
+ const delay = this.wait(queue);
541
+ if (delay > 0) { this.arm(id, delay); break; }
278
542
  const preface = this.prefaces.get(id);
279
- this.prefaces.delete(id);
280
- const batch = this.take(id, queue);
543
+ // Durable queues retain the batch while asynchronous condensation runs. Another publish
544
+ // may checkpoint during that wait; it must not checkpoint the batch out of existence.
545
+ if (!this.journal) this.prefaces.delete(id);
546
+ const batch = this.take(id, queue, !this.journal);
281
547
  const delivery = preface ? [preface, ...batch] : batch;
282
- // What goes out may be condensed; what comes back on failure is always the originals. An important envelope
283
- // is why the queue became ready, so it never waits on a model call.
284
548
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
285
549
  this.condensing += mayCondense ? 1 : 0;
286
550
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
287
551
  this.condensing -= mayCondense ? 1 : 0;
288
- if (this.recoveryHeld || (mayCondense && this.stateOf(id) !== "idle")) {
289
- // The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
290
- // without counting against the envelopes.
291
- if (preface) this.prefaces.set(id, preface);
292
- queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id)));
552
+ if (this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.stateOf(id) !== "idle") {
553
+ if (!this.journal) { if (preface) this.prefaces.set(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
293
554
  break;
294
555
  }
295
- // A reply may name any item of `out` as its parent (Claude's reply_to): the digest has to be resolvable too.
556
+ if (this.journal) {
557
+ if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
558
+ for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
559
+ this.prefaces.delete(id);
560
+ }
296
561
  for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
562
+ while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
297
563
  this.lastDelivery.set(id, { out, originals: delivery });
564
+ const deliveryId = crypto.randomUUID();
565
+ if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
298
566
  try {
299
- await peer.deliver(out);
567
+ if (this.journal) await peer.deliver(out, deliveryId);
568
+ else await peer.deliver(out);
300
569
  } catch {
301
- this.failed(id, delivery);
570
+ if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
571
+ else this.failed(id, delivery);
302
572
  break;
303
573
  }
304
574
  }
305
- } finally {
306
- this.draining.delete(id);
307
- }
575
+ } catch { this.storageError = "delivery journal unavailable"; }
576
+ finally { this.draining.delete(id); this.onQueues?.(); }
308
577
  }
309
578
 
310
579
  /**
311
580
  * The next delivery: important envelopes first (they are why the queue is ready), then the rest in order.
312
581
  * An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
313
582
  */
314
- private take(id: PeerId, queue: Envelope[]): Envelope[] {
583
+ private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
315
584
  const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
316
585
  const batch: Envelope[] = [];
317
586
  for (const env of ordered) {
@@ -320,7 +589,7 @@ export class Bus {
320
589
  batch.push(env);
321
590
  if (failedBefore || batch.length === DIGEST_MAX) break;
322
591
  }
323
- for (const env of batch) queue.splice(queue.indexOf(env), 1);
592
+ if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
324
593
  return batch;
325
594
  }
326
595
 
@@ -347,6 +616,7 @@ export class Bus {
347
616
  return false;
348
617
  });
349
618
  this.queues.get(id)!.unshift(...keep);
619
+ this.persist();
350
620
  // The idle transition caused by the failure itself may have been swallowed by the `draining` guard.
351
621
  this.arm(id, this.opts.retryMs);
352
622
  }
@@ -6,8 +6,10 @@ export function stateDirFor(cwd: string): string {
6
6
  return projectContext(cwd).stateDir;
7
7
  }
8
8
 
9
- /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata. The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 9; // controlled restart/recovery plus the Pi bridge contract
9
+ /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts. The plugin is installed apart from the daemon, so they can drift. */
10
+ export const PROTOCOL = 10;
11
+ /** Protocols a current coordinator may authenticate while upgrading a running source. */
12
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, PROTOCOL] as const;
11
13
 
12
14
  export interface Hello {
13
15
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
@@ -53,7 +55,7 @@ export class ControlClient {
53
55
 
54
56
  /** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
55
57
  static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol = PROTOCOL): Promise<ControlClient> {
56
- if (protocol !== 8 && protocol !== PROTOCOL) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
58
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
57
59
  const control = readControl(stateDir);
58
60
  if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
59
61
  if (control.protocol !== undefined && control.protocol !== protocol) {