@staix/agent-hub 0.6.4 → 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,6 +86,11 @@ 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>();
77
94
  /**
78
95
  * The queue lengths changed. `publish` emits its envelope event *before* it enqueues, and a delivery emits
79
96
  * nothing at all, so anything that renders `queued` off a bus event records the count from before the change
@@ -83,14 +100,27 @@ export class Bus {
83
100
  private recoveryHeld = false;
84
101
  private steering = 0;
85
102
  private condensing = 0;
103
+ private closed = false;
104
+ storageError?: string;
86
105
 
87
106
  constructor(opts: Partial<BusOptions> = {}) {
88
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
+ }
89
118
  }
90
119
 
91
120
  add(peer: PeerAdapter): void {
92
121
  this.peers.set(peer.id, peer);
93
122
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
123
+ peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
94
124
  peer.onMessage = (text, opts) => {
95
125
  const { priority, body } = parseMarker(text);
96
126
  if (!body) return;
@@ -109,14 +139,51 @@ export class Bus {
109
139
  // The adapter got the condensed list; what has to come back is what that list replaced.
110
140
  const last = this.lastDelivery.get(peer.id);
111
141
  const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
112
- 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);
113
152
  };
114
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
+ }
115
159
  this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
116
160
  void this.drain(peer.id);
117
161
  };
118
162
  }
119
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
+
120
187
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
121
188
  setRecoveryHold(held: boolean): void {
122
189
  this.recoveryHeld = held;
@@ -134,20 +201,35 @@ export class Bus {
134
201
  }
135
202
 
136
203
  /** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
137
- snapshot(): BusSnapshot {
138
- return {
204
+ snapshot(includeJournal = true): BusSnapshot {
205
+ const out: BusSnapshot = {
139
206
  schemaVersion: 1,
140
207
  queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
141
208
  prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
142
209
  seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
143
210
  attempts: Object.fromEntries(this.attempts),
144
211
  withdrawn: [...this.withdrawn],
212
+ manualPaused: [...this.manualPaused],
145
213
  };
214
+ if (includeJournal && this.journal) out.journal = this.journal.snapshot();
215
+ return out;
146
216
  }
147
217
 
148
218
  /** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
149
- restore(snapshot: BusSnapshot): void {
219
+ restore(snapshot: BusSnapshot, _operationId?: string): void {
150
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 {
151
233
  this.queues.clear();
152
234
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
153
235
  this.prefaces.clear();
@@ -158,6 +240,122 @@ export class Bus {
158
240
  for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
159
241
  this.withdrawn.clear();
160
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
+ }
161
359
  }
162
360
 
163
361
  queueIds(id: PeerId): string[] {
@@ -181,11 +379,13 @@ export class Bus {
181
379
 
182
380
  pause(id: PeerId): void {
183
381
  this.paused.add(id);
382
+ this.persist();
184
383
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
185
384
  }
186
385
 
187
386
  resume(id: PeerId): void {
188
387
  if (!this.paused.delete(id)) return;
388
+ this.persist();
189
389
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
190
390
  void this.drain(id);
191
391
  }
@@ -197,13 +397,16 @@ export class Bus {
197
397
  // Recovery and session recall may both contribute context. Keep the original id/hop so a queued
198
398
  // preface remains deduplicated and append the new, separately generated context.
199
399
  this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
400
+ this.persist();
200
401
  return;
201
402
  }
202
403
  this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
404
+ this.persist();
203
405
  }
204
406
 
205
407
  /** Returns the peers the envelope was queued for. */
206
408
  publish(env: Envelope): PeerId[] {
409
+ if (this.storageError) throw new Error("delivery journal unavailable");
207
410
  if (this.seen.has(env.id)) return [];
208
411
  this.seen.set(env.id, env);
209
412
  if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
@@ -211,16 +414,36 @@ export class Bus {
211
414
  // fyi is for the record (console now, task board from M4), never worth an agent's turn.
212
415
  const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
213
416
  this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
214
- if (dropped) return [];
215
-
216
- const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
217
- for (const id of targets) {
218
- const peer = this.peers.get(id)!;
219
- 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") {
220
426
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
221
427
  this.steering++;
222
- 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--; });
223
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
+ }
224
447
  }
225
448
  return targets;
226
449
  }
@@ -244,6 +467,7 @@ export class Bus {
244
467
  if (i !== -1) removed = queue.splice(i, 1).length > 0;
245
468
  }
246
469
  if (removed) this.onQueues?.();
470
+ this.persist();
247
471
  return removed;
248
472
  }
249
473
 
@@ -262,16 +486,19 @@ export class Bus {
262
486
 
263
487
  private enqueue(id: PeerId, env: Envelope, front = false): void {
264
488
  if (this.withdrawn.has(env.id)) return;
265
- const queue = this.queues.get(id)!;
489
+ const queue = this.queues.get(id) ?? [];
490
+ this.queues.set(id, queue);
266
491
  if (front) queue.unshift(env);
267
492
  else queue.push(env);
268
493
  if (queue.length > this.opts.queueCap) {
269
494
  const victim = queue.findIndex((e) => e.priority !== "important");
270
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" });
271
497
  this.emit({ t: "overflow", env: lost!, peer: id });
272
498
  }
273
499
  this.onQueues?.();
274
- void this.drain(id);
500
+ if (!this.suppressDrain) this.persist();
501
+ if (!this.suppressDrain) void this.drain(id);
275
502
  }
276
503
 
277
504
  /** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
@@ -280,56 +507,80 @@ export class Bus {
280
507
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
281
508
  }
282
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
+
283
533
  private async drain(id: PeerId): Promise<void> {
284
- if (this.draining.has(id)) return;
534
+ if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
285
535
  this.draining.add(id);
286
536
  try {
287
537
  const peer = this.peers.get(id)!;
288
538
  const queue = this.queues.get(id)!;
289
- while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
290
- const wait = this.wait(queue);
291
- if (wait > 0) {
292
- this.arm(id, wait);
293
- break;
294
- }
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; }
295
542
  const preface = this.prefaces.get(id);
296
- this.prefaces.delete(id);
297
- 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);
298
547
  const delivery = preface ? [preface, ...batch] : batch;
299
- // What goes out may be condensed; what comes back on failure is always the originals. An important envelope
300
- // is why the queue became ready, so it never waits on a model call.
301
548
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
302
549
  this.condensing += mayCondense ? 1 : 0;
303
550
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
304
551
  this.condensing -= mayCondense ? 1 : 0;
305
- if (this.recoveryHeld || (mayCondense && this.stateOf(id) !== "idle")) {
306
- // The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
307
- // without counting against the envelopes.
308
- if (preface) this.prefaces.set(id, preface);
309
- 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))); }
310
554
  break;
311
555
  }
312
- // 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
+ }
313
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);
314
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); }
315
566
  try {
316
- await peer.deliver(out);
567
+ if (this.journal) await peer.deliver(out, deliveryId);
568
+ else await peer.deliver(out);
317
569
  } catch {
318
- this.failed(id, delivery);
570
+ if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
571
+ else this.failed(id, delivery);
319
572
  break;
320
573
  }
321
574
  }
322
- } finally {
323
- this.draining.delete(id);
324
- this.onQueues?.();
325
- }
575
+ } catch { this.storageError = "delivery journal unavailable"; }
576
+ finally { this.draining.delete(id); this.onQueues?.(); }
326
577
  }
327
578
 
328
579
  /**
329
580
  * The next delivery: important envelopes first (they are why the queue is ready), then the rest in order.
330
581
  * An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
331
582
  */
332
- private take(id: PeerId, queue: Envelope[]): Envelope[] {
583
+ private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
333
584
  const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
334
585
  const batch: Envelope[] = [];
335
586
  for (const env of ordered) {
@@ -338,7 +589,7 @@ export class Bus {
338
589
  batch.push(env);
339
590
  if (failedBefore || batch.length === DIGEST_MAX) break;
340
591
  }
341
- 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);
342
593
  return batch;
343
594
  }
344
595
 
@@ -365,6 +616,7 @@ export class Bus {
365
616
  return false;
366
617
  });
367
618
  this.queues.get(id)!.unshift(...keep);
619
+ this.persist();
368
620
  // The idle transition caused by the failure itself may have been swallowed by the `draining` guard.
369
621
  this.arm(id, this.opts.retryMs);
370
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) {