@staix/agent-hub 0.12.29 → 0.12.31

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
package/src/hub/bus.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
- import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
3
+ import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
+ import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
5
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
6
 
5
7
  export type { DeliveryReceipt } from "./peers.ts";
6
8
 
@@ -135,6 +137,8 @@ export class Bus {
135
137
  onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
136
138
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
137
139
  onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
140
+ /** Final rendered journal payload, and its first positive acknowledgement. Observation cannot alter settlement. */
141
+ onMemoryDelivery?: (peer: PeerId, deliveryId: string, out: Envelope[], field: "rendered" | "delivered") => void;
138
142
  /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
139
143
  onDeliveryFailed?: (peer: PeerId) => void;
140
144
  private recoveryHeld = false;
@@ -246,10 +250,11 @@ export class Bus {
246
250
  catch { this.storageError = "delivery journal unavailable"; throw new Error("delivery journal unavailable"); }
247
251
  }
248
252
 
249
- private durableHandoff(peer: PeerId, deliveryId: string, originals: Envelope[], out: Envelope[]): void {
253
+ private durableHandoff(peer: PeerId, deliveryId: string, originals: Envelope[], out: Envelope[], observe = true): void {
250
254
  if (!this.journal) return;
251
255
  const delivery = { id: deliveryId, peer, state: "dispatching" as const, createdAt: Date.now(), originals, out };
252
256
  this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery);
257
+ if (observe) this.memoryDelivery(peer, deliveryId, out, "rendered", originals);
253
258
  }
254
259
 
255
260
  private snapshotWithoutJournal(): BusSnapshot {
@@ -259,6 +264,7 @@ export class Bus {
259
264
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
260
265
  setRecoveryHold(held: boolean): void {
261
266
  this.recoveryHeld = held;
267
+ if (!held) this.onQueues?.(); // restored holds need classification even when no peer is attached
262
268
  if (!held) for (const id of this.peers.keys()) void this.drain(id);
263
269
  }
264
270
 
@@ -327,7 +333,7 @@ export class Bus {
327
333
  this.queues.clear();
328
334
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
329
335
  this.prefaces.clear();
330
- for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
336
+ for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, body: uniqueMemoryLines(e.body), ...(e.to ? { to: [...e.to] } : {}) });
331
337
  this.seen.clear();
332
338
  for (const e of snapshot.seen ?? []) this.seen.set(e.id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
333
339
  this.attempts.clear();
@@ -357,16 +363,41 @@ export class Bus {
357
363
 
358
364
  queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
359
365
 
360
- resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string): void {
366
+ heldDeliveryHints(peer: string) { return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer); }
367
+
368
+ /** Consent is for this peer's held row only, never authority to complete/discard or release a pause. */
369
+ retryDelivery(peer: string, id: string): void {
370
+ const refuse = () => { throw new PreEffectToolRefusal("recipient retry unavailable; inspect the held delivery or ask the person"); };
371
+ if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
372
+ const row = this.queueShow(id);
373
+ if (!row || row.peer !== peer || row.state !== "needs_review" || !this.journal || this.journal.retryEvidence(peer, row.originals) !== "unused") return refuse();
374
+ this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
375
+ }
376
+
377
+ private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
378
+ if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
379
+ return out.map((env, index) => {
380
+ if (index !== 0) return env;
381
+ const prefix = `${DELIVERY_RETRY_WARNING}\n\n`, marker = "\n[cut: rendered body exceeds delivery limit]";
382
+ if (prefix.length + env.body.length <= MAX_ENVELOPE_BODY) return { ...env, body: prefix + env.body };
383
+ let end = MAX_ENVELOPE_BODY - prefix.length - marker.length;
384
+ // The journal cap uses UTF-16 length; never cut between a Unicode surrogate pair.
385
+ if (Array.from(env.body.slice(end - 1, end + 1))[0]?.length === 2) end--;
386
+ return { ...env, body: prefix + env.body.slice(0, end) + marker };
387
+ });
388
+ }
389
+
390
+ resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
361
391
  if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
362
392
  const record = this.queueShow(id);
363
393
  if (!record) throw new Error("unknown delivery");
364
394
  const previous = this.journal.resolution(id);
365
395
  if (previous) {
366
- this.journal.resolve(id, revision, action, reason); // verifies identical request; no second enqueue
396
+ this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
367
397
  return;
368
398
  }
369
- if (["dispatching", "accepted"].includes(record.state) || this.peers.get(record.peer)?.state === "busy") throw new Error("active delivery cannot be resolved");
399
+ const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
400
+ if (["dispatching", "accepted"].includes(record.state) || (this.peers.get(record.peer)?.state === "busy" && !recipientConsent)) throw new Error("active delivery cannot be resolved");
370
401
  if (record.revision !== revision) throw new Error("stale delivery revision");
371
402
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
372
403
  if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -378,17 +409,17 @@ export class Bus {
378
409
  this.journal.transaction(() => {
379
410
  if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
380
411
  const current = this.journal!.get(id)!;
381
- this.journal!.resolve(id, current.revision, action, reason, revision);
412
+ this.journal!.resolve(id, current.revision, action, reason, revision, actor);
382
413
  if (record.state === "queued") {
383
414
  const ids = new Set(record.originals.map((env) => env.id));
384
415
  queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
385
416
  }
386
- if (action === "retry") {
417
+ if (action === "retry" || action === "completed" && actor === "hub") {
387
418
  for (const env of record.originals) {
388
419
  if (env.from === HUB && env.kind === "presence") this.restorePreface(record.peer, env);
389
- else if (!queue.some((old) => old.id === env.id)) queue.push(env);
420
+ else if (action === "retry" && !queue.some((old) => old.id === env.id)) queue.push(env);
390
421
  }
391
- this.queues.set(record.peer, queue);
422
+ if (action === "retry") this.queues.set(record.peer, queue);
392
423
  }
393
424
  this.persist();
394
425
  });
@@ -433,8 +464,12 @@ export class Bus {
433
464
  const before = this.journal.get(id);
434
465
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
435
466
  if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
436
- this.journal.transition(id, state, receipt.reason);
437
- if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
467
+ if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
468
+ else this.journal.transition(id, state, receipt.reason);
469
+ if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
470
+ this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
471
+ this.delivered(peer, before.originals);
472
+ }
438
473
  if (state === "failed") {
439
474
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
440
475
  }
@@ -457,8 +492,9 @@ export class Bus {
457
492
  for (const [id, owner] of this.activeDeliveries) {
458
493
  if (owner !== peer) continue;
459
494
  const row = this.journal.get(id);
460
- if (row?.out.some((e) => e.id === envelopeId)) {
461
- this.journal.transition(id, "completed", "correlated reply");
495
+ if (row?.peer === peer && ["dispatching", "accepted"].includes(row.state) && row.out.some((e) => e.id === envelopeId)) {
496
+ this.journal.completeDelivered(id, peer, "reply", "correlated reply");
497
+ if (row.state === "dispatching") this.memoryDelivery(peer, id, row.out, "delivered", row.originals);
462
498
  this.activeDeliveries.delete(id);
463
499
  this.refreshRecoveryHold(peer);
464
500
  this.onQueues?.();
@@ -529,12 +565,12 @@ export class Bus {
529
565
 
530
566
  /** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
531
567
  preface(id: PeerId, text: string): void {
532
- const body = quoteNotes(text);
568
+ const body = uniqueMemoryLines(quoteNotes(text));
533
569
  const existing = this.prefaces.get(id);
534
570
  if (existing) {
535
- // Recovery and session recall may both contribute context. Keep the original id/hop so a queued
536
- // preface remains deduplicated and append the new, separately generated context.
537
- this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
571
+ // A fresh daemon recalls again after restoring queued context. Merge repeated blocks,
572
+ // retain distinct context and keep the original envelope id/hop for retry bookkeeping.
573
+ this.prefaces.set(id, { ...existing, body: mergePrefaces(existing.body, body) });
538
574
  this.persist();
539
575
  return;
540
576
  }
@@ -545,14 +581,14 @@ export class Bus {
545
581
  /** Another peer's shared note: rides in the preface like recall. The newest NOTES_KEPT stay; older ones remain in claude-mem. */
546
582
  note(id: PeerId, line: string): void {
547
583
  const existing = this.prefaces.get(id);
548
- this.prefaces.set(id, existing ? { ...existing, body: keepNotes(appendNote(existing.body, line), NOTES_KEPT) } : newEnvelope(HUB, line, { to: [id], kind: "presence" }));
584
+ this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
549
585
  this.persist();
550
586
  }
551
587
 
552
588
  /** A preface that went out with a failed delivery comes back ahead of whatever was prefaced meanwhile, instead of replacing it. */
553
589
  private restorePreface(id: PeerId, env: Envelope): void {
554
590
  const now = this.prefaces.get(id);
555
- this.prefaces.set(id, !now ? env : now.id === env.id ? now : { ...env, body: keepNotes(`${env.body}\n\n${now.body}`, NOTES_KEPT) });
591
+ this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
556
592
  }
557
593
 
558
594
  /** Returns the peers the envelope was queued for. */
@@ -637,6 +673,11 @@ export class Bus {
637
673
  return this.seen.get(id);
638
674
  }
639
675
 
676
+ /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
677
+ lastDeliveredTo(peer: PeerId): Envelope[] {
678
+ return this.lastDelivery.get(peer)?.out ?? [];
679
+ }
680
+
640
681
  /**
641
682
  * Take back an envelope that has not been delivered yet (a request that has expired). True when it was removed from a
642
683
  * queue now; an envelope whose steer is still in flight is not in any queue, so its id is remembered and it is
@@ -684,6 +725,11 @@ export class Bus {
684
725
  this.emit({ t: "stale", env, peer, reason });
685
726
  }
686
727
 
728
+ private memoryDelivery(peer: PeerId, id: string, out: Envelope[], field: "rendered" | "delivered", originals: Envelope[] = out): void {
729
+ if ([...out, ...originals].some(env => env.private)) return;
730
+ try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
731
+ }
732
+
687
733
  private delivered(peer: PeerId, originals: Envelope[]): void {
688
734
  try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
689
735
  }
@@ -766,14 +812,20 @@ export class Bus {
766
812
  if (index < 0) return;
767
813
  this.steering++;
768
814
  const deliveryId = crypto.randomUUID();
815
+ let removed = false, handedOff = false;
769
816
  try {
817
+ const out = this.retryOut(id, [env], [env]);
770
818
  queue.splice(index, 1);
771
- this.durableHandoff(id, deliveryId, [env], [env]);
819
+ removed = true;
820
+ this.durableHandoff(id, deliveryId, [env], out);
821
+ handedOff = true;
772
822
  this.activeDeliveries.set(deliveryId, id);
773
- try { await peer.steer([env], deliveryId); }
823
+ try { await peer.steer(out, deliveryId); }
774
824
  catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
775
- } catch { this.storageError = "delivery journal unavailable"; }
776
- finally { this.steering--; this.onQueues?.(); }
825
+ } catch {
826
+ if (removed && !handedOff) queue.splice(index, 0, env);
827
+ this.storageError = "delivery journal unavailable";
828
+ } finally { this.steering--; this.onQueues?.(); }
777
829
  }
778
830
 
779
831
  /** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
@@ -812,14 +864,16 @@ export class Bus {
812
864
  if (!preface && !queue.length) return [];
813
865
  const queued = [...queue];
814
866
  const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
815
- this.prefaces.delete(id);
816
- // Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
817
- this.remember(batch);
867
+ let out: Envelope[] = [];
868
+ const deliveryId = crypto.randomUUID();
818
869
  try {
819
- const deliveryId = crypto.randomUUID();
870
+ out = this.retryOut(id, batch, batch);
871
+ this.prefaces.delete(id);
872
+ // Before the checkpoint, as in a drain, so the persisted snapshot carries it; a failed policy read rolls back too.
873
+ this.remember(out);
820
874
  this.journal?.transaction(() => {
821
- this.durableHandoff(id, deliveryId, batch, batch); // its bus snapshot is the queue after this pull
822
- this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
875
+ this.durableHandoff(id, deliveryId, batch, out, false); // observe only after this outer transaction commits
876
+ this.journal!.completeDelivered(deliveryId, id, "pull", "read through hub_inbox");
823
877
  });
824
878
  } catch (error) {
825
879
  queue.splice(0, queue.length, ...queued);
@@ -827,9 +881,13 @@ export class Bus {
827
881
  this.storageError = "delivery journal unavailable";
828
882
  throw error;
829
883
  }
884
+ if (this.journal) {
885
+ this.memoryDelivery(id, deliveryId, out, "rendered", batch);
886
+ this.memoryDelivery(id, deliveryId, out, "delivered", batch);
887
+ }
830
888
  this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
831
889
  this.onQueues?.();
832
- return batch;
890
+ return out;
833
891
  }
834
892
 
835
893
  private async drain(id: PeerId): Promise<void> {
@@ -851,8 +909,11 @@ export class Bus {
851
909
  const delivery = preface ? [preface, ...batch] : batch;
852
910
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
853
911
  this.condensing += mayCondense ? 1 : 0;
854
- const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
855
- this.condensing -= mayCondense ? 1 : 0;
912
+ let out: Envelope[];
913
+ try {
914
+ const condensed = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
915
+ out = this.retryOut(id, delivery, condensed);
916
+ } finally { this.condensing -= mayCondense ? 1 : 0; }
856
917
  // A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
857
918
  if (!this.deliverable(id)) {
858
919
  if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
@@ -864,16 +925,25 @@ export class Bus {
864
925
  this.dropIrrelevant(id, queue);
865
926
  continue;
866
927
  }
928
+ const deliveryId = crypto.randomUUID();
867
929
  if (this.journal) {
868
930
  if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
931
+ const queued = [...queue], seen = [...this.seen];
869
932
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
870
933
  this.prefaces.delete(id);
871
- }
872
- this.remember(out);
934
+ this.remember(out); // the checkpoint must retain rendered reply ids across a restart
935
+ try { this.durableHandoff(id, deliveryId, delivery, out); }
936
+ catch (error) {
937
+ queue.splice(0, queue.length, ...queued);
938
+ if (preface) this.prefaces.set(id, preface);
939
+ this.seen.clear();
940
+ for (const [key, env] of seen) this.seen.set(key, env);
941
+ throw error;
942
+ }
943
+ this.activeDeliveries.set(deliveryId, id);
944
+ } else this.remember(out);
873
945
  this.lastDelivery.set(id, { out, originals: delivery });
874
946
  this.preparing.delete(id); // from here on the transport/journal owns settlement
875
- const deliveryId = crypto.randomUUID();
876
- if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
877
947
  try {
878
948
  try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
879
949
  if (this.journal) await peer.deliver(out, deliveryId);