@staix/agent-hub 0.30.0 → 0.31.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/CHANGELOG.md +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/hub/bus.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { isDeliveryFailureCause, type DeliveryFailureCause } from "./delivery-failure.ts";
|
|
2
|
+
import { isReviewObserverExcluded } from "./memory-policy.ts";
|
|
1
3
|
import { remoteEnvelope, appendNote, DIGEST, frame, HUB, USER, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
4
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
5
|
import type { DoingKind } from "./peer-doing.ts";
|
|
4
6
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
5
|
-
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
|
|
7
|
+
import { ACCEPTED_SETTLE_TIMEOUT_REASON, DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
|
|
6
8
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
7
9
|
import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
8
10
|
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
@@ -15,6 +17,12 @@ export interface PublishResult {
|
|
|
15
17
|
dropped?: "hop" | "fyi";
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
export interface AcceptedDeliveryExpiry { id: string; peer: PeerId; revision: number; notifyConductor: boolean }
|
|
21
|
+
|
|
22
|
+
export class ReviewInputHandoffError extends Error {
|
|
23
|
+
constructor() { super("review input provenance unavailable; the message remains queued. Reconnect the peer, check ahub status for journal health, then retry hub_inbox."); this.name = "ReviewInputHandoffError"; }
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export type BusEvent =
|
|
19
27
|
| { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
|
|
20
28
|
| { t: "overflow"; env: Envelope; peer: PeerId }
|
|
@@ -22,9 +30,16 @@ export type BusEvent =
|
|
|
22
30
|
| { t: "stale"; env: Envelope; peer: PeerId; reason: string }
|
|
23
31
|
| { t: "quiet"; env: Envelope; peers: PeerId[]; reason: string }
|
|
24
32
|
| { t: "off_roster"; env: Envelope; peers: PeerId[] }
|
|
33
|
+
| { t: "remote_delivery_settled"; peer: PeerId; deliveryId: string; outcome: "completed" | "failed_safe" }
|
|
25
34
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
26
35
|
|
|
27
36
|
export interface BusOptions {
|
|
37
|
+
/** Durable host-only comment provenance; no peer-supplied marker or task authority. */
|
|
38
|
+
reviewObserverScope?: (peer: PeerId) => string | undefined;
|
|
39
|
+
/** Host task/input screening for observer-memory hooks; receives originals, never rendered output. */
|
|
40
|
+
reviewObserverInputExcluded?: (peer: PeerId, originals: Envelope[]) => boolean;
|
|
41
|
+
/** Required durable provenance before a review-scoped input crosses a native handoff boundary; throws to refuse handoff. */
|
|
42
|
+
beforeReviewInputHandoff?: (peer: PeerId, originals: Envelope[]) => void;
|
|
28
43
|
/** Durable application outbox ids; the bus combines them with its own pending ids without recursion. */
|
|
29
44
|
remotePending?: (room: string) => readonly string[];
|
|
30
45
|
/** Host provenance at enqueue, never a body screen; unknown/throw marks the original private. */
|
|
@@ -43,6 +58,8 @@ export interface BusOptions {
|
|
|
43
58
|
queueCap: number;
|
|
44
59
|
/** Accepted live operational settings; read only at the next matching delivery/note operation. */
|
|
45
60
|
maxAttempts?: () => number;
|
|
61
|
+
/** Maximum age of an accepted explicit-channel delivery before it needs review. */
|
|
62
|
+
acceptedSettleMs?: () => number;
|
|
46
63
|
digestMax?: () => number;
|
|
47
64
|
pullBudgetBytes?: () => number;
|
|
48
65
|
notesKept?: () => number;
|
|
@@ -143,6 +160,8 @@ export const NOTES_KEPT = 10;
|
|
|
143
160
|
* delivered as a whole (one envelope, or a digest) when the peer is idle and the queue is ready:
|
|
144
161
|
* it holds an `important` envelope, or `batchMax` envelopes, or its oldest one has waited `batchMs`.
|
|
145
162
|
*/
|
|
163
|
+
export const ROOM_PROOF_UNAVAILABLE_REASON = "room delivery proof unavailable; inspect ahub queue show";
|
|
164
|
+
|
|
146
165
|
export class Bus {
|
|
147
166
|
readonly peers = new Map<PeerId, PeerAdapter>();
|
|
148
167
|
private readonly opts: BusOptions;
|
|
@@ -167,7 +186,13 @@ export class Bus {
|
|
|
167
186
|
private readonly taps = new Set<(e: BusEvent) => void>();
|
|
168
187
|
private readonly withdrawn = new Set<string>();
|
|
169
188
|
/** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
|
|
189
|
+
private readonly reviewParents = new Map<PeerId, Map<string, string>>();
|
|
190
|
+
private readonly reviewResponses = new Map<string, { peer: PeerId; source: string }>();
|
|
191
|
+
private reviewScopeRevision = -1;
|
|
192
|
+
private readonly reviewScopeCache = new Map<string, boolean>();
|
|
170
193
|
private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
|
|
194
|
+
/** Latest inbox handout; kept separately so it cannot replace a pushed invocation's answer parent. */
|
|
195
|
+
private readonly lastPulled = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
|
|
171
196
|
private readonly failureStreak = new Map<PeerId, number>();
|
|
172
197
|
private readonly lastFailure = new Map<PeerId, string>();
|
|
173
198
|
private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
|
|
@@ -203,7 +228,7 @@ export class Bus {
|
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
/** Hold the existing delivery rather than cancelling native work or replaying an uncertain effect. */
|
|
206
|
-
holdInvocation(id: string, reason: string, target?: PeerId): void {
|
|
231
|
+
holdInvocation(id: string, reason: string, target?: PeerId, failureCause?: DeliveryFailureCause): void {
|
|
207
232
|
const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
|
|
208
233
|
if (!row || !this.journal) {
|
|
209
234
|
this.storageError = "uncertain invocation delivery evidence unavailable; inspect the invocation ledger before recovery";
|
|
@@ -211,7 +236,7 @@ export class Bus {
|
|
|
211
236
|
this.onQueues?.();
|
|
212
237
|
return;
|
|
213
238
|
}
|
|
214
|
-
this.journal.transition(row.id, "needs_review", reason);
|
|
239
|
+
this.journal.transition(row.id, "needs_review", reason, undefined, failureCause ?? row.failureCause ?? "turn aborted");
|
|
215
240
|
this.activeDeliveries.delete(row.id);
|
|
216
241
|
this.recoveryHeldPeers.add(row.peer);
|
|
217
242
|
this.onQueues?.();
|
|
@@ -219,6 +244,10 @@ export class Bus {
|
|
|
219
244
|
|
|
220
245
|
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
221
246
|
onDelivered?: (peer: PeerId, originals: Envelope[], deliveryId?: string) => void;
|
|
247
|
+
/** Metadata-only current-input observation after a successful inbox handout; never a transport ACK. */
|
|
248
|
+
onPulled?: (peer: PeerId, originals: Envelope[]) => void;
|
|
249
|
+
/** Metadata-only observation when an important message actually begins a steer; never a transport ACK. */
|
|
250
|
+
onSteering?: (peer: PeerId, originals: Envelope[]) => void;
|
|
222
251
|
/** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
|
|
223
252
|
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
224
253
|
/** Final rendered journal payload, and its first positive acknowledgement. Observation cannot alter settlement. */
|
|
@@ -243,13 +272,13 @@ export class Bus {
|
|
|
243
272
|
this.journal = opts.journal;
|
|
244
273
|
this.trackingQueuePressure();
|
|
245
274
|
if (this.journal) {
|
|
246
|
-
const state = this.journal.
|
|
275
|
+
const state = this.journal.transportSnapshot();
|
|
247
276
|
const bus = state.bus as BusSnapshot;
|
|
248
277
|
if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
|
|
249
278
|
for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
|
|
250
279
|
for (const row of this.journal.list()) {
|
|
251
280
|
for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
|
|
252
|
-
if (row.state === "
|
|
281
|
+
if (row.state === "dispatching" || row.state === "accepted" || row.state === "needs_review" && (!remoteId(row.peer) || row.originals.some(isInvocationKind))) this.recoveryHeldPeers.add(row.peer);
|
|
253
282
|
}
|
|
254
283
|
}
|
|
255
284
|
}
|
|
@@ -277,29 +306,38 @@ export class Bus {
|
|
|
277
306
|
else if (member !== peer.remoteMember) throw new Error("remote queued member binding changed");
|
|
278
307
|
}
|
|
279
308
|
if (peer.remoteRoom !== undefined && !pendingRemote) { this.remoteQueueRooms.delete(peer.id); this.remoteQueueMembers.delete(peer.id); }
|
|
309
|
+
this.clearReviewProvenance(peer.id);
|
|
280
310
|
this.peers.set(peer.id, peer);
|
|
281
311
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
282
|
-
peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
|
|
283
|
-
peer.onDoing = (kind, text) => { if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
|
|
312
|
+
peer.onDelivery = (receipt) => { if (this.peers.get(peer.id) === peer) this.deliveryReceipt(peer.id, receipt); };
|
|
313
|
+
peer.onDoing = (kind, text) => { if (kind === "reset") this.clearReviewProvenance(peer.id); if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
|
|
284
314
|
this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
|
|
285
315
|
peer.onMessage = (text, opts) => {
|
|
286
316
|
if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
|
|
287
317
|
// #445 C2: an answer to an invocation is found by what the peer was handed, never by how the answer is addressed, so a headless agent whose final answer is
|
|
288
318
|
// normally shared with every peer cannot leak it. It is the invocation's answer when it names the request itself (an adapter may report its delivery complete before
|
|
289
|
-
// it answers, so the receipt cannot decide), or when it names nothing while that invocation's own delivery is still the peer's open turn.
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
319
|
+
// it answers, so the receipt cannot decide), or when it names nothing while that invocation's own pushed delivery is still the peer's open turn. A pull updates
|
|
320
|
+
// current-input provenance separately, so it never replaces this answer parent; after the invocation turn ends, a later unnamed message is ordinary.
|
|
321
|
+
const pushed = this.lastDelivery.get(peer.id), pulled = this.lastPulled.get(peer.id), parentId = opts?.inReplyTo?.id;
|
|
322
|
+
const namedPush = parentId !== undefined && pushed?.out.some(e => e.id === parentId);
|
|
323
|
+
const namedPull = parentId !== undefined && pulled?.out.some(e => e.id === parentId);
|
|
324
|
+
const pushedInvocation = pushed?.out.some(e => e.kind === "invocation") === true;
|
|
325
|
+
const pulledInvocation = pulled?.out.some(e => e.kind === "invocation") === true;
|
|
326
|
+
const answerParent = namedPush && pushedInvocation ? pushed
|
|
327
|
+
: namedPull && pulledInvocation ? pulled
|
|
328
|
+
: parentId === undefined && this.invocationTurnOpen(peer.id)
|
|
329
|
+
? pushedInvocation ? pushed : pulledInvocation ? pulled : undefined
|
|
330
|
+
: undefined;
|
|
331
|
+
if (this.onAnswer && answerParent && (namedPush || namedPull || parentId === undefined && this.invocationTurnOpen(peer.id))) {
|
|
294
332
|
let taken = false;
|
|
295
|
-
try { taken = this.onAnswer(peer.id, text,
|
|
333
|
+
try { taken = this.onAnswer(peer.id, text, answerParent.out) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
|
|
296
334
|
if (taken) return;
|
|
297
335
|
}
|
|
298
336
|
const { priority, body } = parseMarker(text);
|
|
299
337
|
if (!body) return;
|
|
300
338
|
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
301
339
|
// and the priority ceiling are about what it stands for.
|
|
302
|
-
const last =
|
|
340
|
+
const last = (parentId !== undefined && namedPush ? pushed : parentId !== undefined && namedPull ? pulled : pulled ?? pushed);
|
|
303
341
|
const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
|
|
304
342
|
const originals = answers ? last!.originals : undefined;
|
|
305
343
|
// `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
|
|
@@ -307,6 +345,7 @@ export class Bus {
|
|
|
307
345
|
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
308
346
|
const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
|
|
309
347
|
let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
|
|
348
|
+
env = this.reviewResponse(env, opts?.inReplyTo?.id);
|
|
310
349
|
try { this.checkRemotePublication(env); }
|
|
311
350
|
catch (error) {
|
|
312
351
|
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
@@ -360,19 +399,20 @@ export class Bus {
|
|
|
360
399
|
if (owner !== peer.id) continue;
|
|
361
400
|
const row = this.journal.get(deliveryId);
|
|
362
401
|
if (row && row.originals.some((e) => envs.some((incoming) => incoming.id === e.id))) {
|
|
363
|
-
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
|
|
402
|
+
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence", failureCause: failure?.failureCause ?? "turn aborted" });
|
|
364
403
|
}
|
|
365
404
|
}
|
|
366
|
-
} else if (envs.some(env => env.kind === "invocation")) {
|
|
405
|
+
} else if (!remoteId(peer.id) && envs.some(env => env.kind === "invocation")) {
|
|
367
406
|
this.recoveryHeldPeers.add(peer.id);
|
|
368
407
|
for (const env of envs) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
|
|
369
408
|
} else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
|
|
370
409
|
};
|
|
371
410
|
peer.onState = () => {
|
|
411
|
+
if (peer.state === "offline") this.clearReviewProvenance(peer.id);
|
|
372
412
|
if (this.closed) return;
|
|
373
413
|
if (this.peers.get(peer.id) !== peer) return;
|
|
374
414
|
if (peer.state === "offline" && this.journal) {
|
|
375
|
-
for (const [id, owner] of [...this.activeDeliveries]) if (owner === peer.id) this.uncertain(peer.id, id, "peer disconnected before delivery settlement");
|
|
415
|
+
for (const [id, owner] of [...this.activeDeliveries]) if (owner === peer.id) this.uncertain(peer.id, id, "peer disconnected before delivery settlement", "peer offline");
|
|
376
416
|
}
|
|
377
417
|
this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
|
|
378
418
|
void this.drain(peer.id);
|
|
@@ -590,6 +630,13 @@ export class Bus {
|
|
|
590
630
|
return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
|
|
591
631
|
}
|
|
592
632
|
|
|
633
|
+
/** Declare an already-rostered local destination whose adapter is not instantiated yet. */
|
|
634
|
+
ensureKnownRecipientQueue(peer: PeerId): void {
|
|
635
|
+
if (this.closed || this.recoveryHeld || this.storageError) throw new Error("delivery recipient queue unavailable");
|
|
636
|
+
if (typeof peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || peer === HUB || peer === USER || peer === DIGEST || remoteId(peer) || !this.isRostered(peer)) throw new Error("invalid configured local recipient");
|
|
637
|
+
if (!this.queues.has(peer)) this.queues.set(peer, []);
|
|
638
|
+
}
|
|
639
|
+
|
|
593
640
|
queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
|
|
594
641
|
if (this.journal) {
|
|
595
642
|
const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
@@ -603,9 +650,68 @@ export class Bus {
|
|
|
603
650
|
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] })));
|
|
604
651
|
}
|
|
605
652
|
|
|
653
|
+
/** Metadata only: status must not hydrate or sort retained delivery payloads. */
|
|
654
|
+
failedDeliveryCounts(): Record<string, number> | undefined {
|
|
655
|
+
if (this.closed || this.storageError || !this.journal) return undefined;
|
|
656
|
+
try { return this.journal.unresolvedFailedCounts(); }
|
|
657
|
+
catch { this.storageError = "delivery journal unavailable"; return undefined; }
|
|
658
|
+
}
|
|
659
|
+
|
|
606
660
|
queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
|
|
607
661
|
|
|
608
|
-
heldDeliveryHints(peer: string
|
|
662
|
+
heldDeliveryHints(peer: string, includeAccepted = false) {
|
|
663
|
+
const adapter = this.peers.get(peer);
|
|
664
|
+
const canExposeAccepted = includeAccepted && adapter?.acceptedSettlement === "explicit";
|
|
665
|
+
return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer, canExposeAccepted);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Whether timeout-originated holds still protect the recovery cause from supersession. */
|
|
669
|
+
hasAcceptedTimeoutHolds(): boolean {
|
|
670
|
+
if (this.closed || this.storageError || !this.journal) throw new Error("delivery timeout hold state unavailable");
|
|
671
|
+
return this.journal.hasAcceptedTimeoutHolds();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Expire only accepted channel notifications, without replaying or settling their effects. */
|
|
675
|
+
expireAcceptedDeliveries(now = Date.now()): AcceptedDeliveryExpiry[] {
|
|
676
|
+
if (this.closed || this.recoveryHeld || !this.journal || this.storageError) return [];
|
|
677
|
+
const settleMs = this.opts.acceptedSettleMs?.();
|
|
678
|
+
if (!Number.isSafeInteger(settleMs) || settleMs! < 1) return [];
|
|
679
|
+
if (!Number.isSafeInteger(now) || now < 0) return [];
|
|
680
|
+
if (now < settleMs!) return [];
|
|
681
|
+
const expired: AcceptedDeliveryExpiry[] = [];
|
|
682
|
+
const notify = () => { try { this.onQueues?.(); } catch { /* observers cannot consume a durable timeout notice */ } };
|
|
683
|
+
const eligiblePeers = [...this.peers.values()].filter(peer => peer.acceptedSettlement === "explicit").map(peer => peer.id);
|
|
684
|
+
try {
|
|
685
|
+
for (const row of this.journal.expiredAccepted(now - settleMs!, eligiblePeers)) {
|
|
686
|
+
// The synchronous read/transition pair is revision fenced; no await allows a confirmation to interleave.
|
|
687
|
+
const current = this.journal.get(row.id);
|
|
688
|
+
if (!current || current.peer !== row.peer || current.state !== "accepted" || current.revision !== row.revision) continue;
|
|
689
|
+
let updated: JournalDelivery;
|
|
690
|
+
try {
|
|
691
|
+
updated = this.journal.transition(row.id, "needs_review", ACCEPTED_SETTLE_TIMEOUT_REASON, row.revision, "timeout");
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (error instanceof Error && /stale delivery revision/.test(error.message)) continue;
|
|
694
|
+
this.storageError = "delivery journal unavailable";
|
|
695
|
+
notify();
|
|
696
|
+
return expired;
|
|
697
|
+
}
|
|
698
|
+
this.activeDeliveries.delete(row.id);
|
|
699
|
+
if (!remoteId(row.peer)) this.recoveryHeldPeers.add(row.peer);
|
|
700
|
+
expired.push({ id: row.id, peer: row.peer, revision: updated.revision, notifyConductor: row.notifyConductor });
|
|
701
|
+
this.deliveryFailed(current.peer);
|
|
702
|
+
for (const env of current.originals) if (isInvocationKind(env)) {
|
|
703
|
+
try { this.onInvocationReceipt?.(env.id, "needs_review"); } catch { /* an observer cannot suppress timeout metadata */ }
|
|
704
|
+
if (env.kind === "invocation") try { this.onInvocationUncertain?.(env.id); } catch { /* preserve uncertain invocation handling for other ids */ }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
} catch {
|
|
708
|
+
this.storageError = "delivery journal unavailable";
|
|
709
|
+
notify();
|
|
710
|
+
return expired;
|
|
711
|
+
}
|
|
712
|
+
if (expired.length) notify();
|
|
713
|
+
return expired;
|
|
714
|
+
}
|
|
609
715
|
|
|
610
716
|
/** Consent is for this peer's held row only, never authority to complete/discard or release a pause. */
|
|
611
717
|
retryDelivery(peer: string, id: string): void {
|
|
@@ -628,8 +734,19 @@ export class Bus {
|
|
|
628
734
|
const row = this.queueShow(id), actor = `peer:${peer}`, cleanReason = reason.trim();
|
|
629
735
|
const notYours = "no needs_review delivery with this id is held for you; call hub_inbox to list your held deliveries and their revisions";
|
|
630
736
|
if (!row || row.peer !== peer) return refuse(notYours);
|
|
737
|
+
if (this.activeDeliveries.has(id) && this.activeDeliveries.get(id) !== peer) return refuse(notYours);
|
|
631
738
|
// An uncertain invocation may still be running: a peer claiming it completed would make the caller wait for a result that may never come. Only the person settles it.
|
|
632
739
|
if (row.originals.some(env => isInvocationKind(env))) return refuse(`delivery ${id} carries an invocation whose outcome is uncertain; only the person settles it (ahub queue show ${id}, then ahub queue resolve ${id})`);
|
|
740
|
+
if (row.state === "accepted") {
|
|
741
|
+
if (this.peers.get(peer)?.acceptedSettlement !== "explicit") return refuse("only an explicit push channel session can settle its accepted delivery; ask the person to resolve it");
|
|
742
|
+
if (row.revision !== revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
|
|
743
|
+
try { this.resolveDelivery(id, revision, action, cleanReason, actor); }
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error instanceof Error && /stale delivery revision/.test(error.message)) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
|
|
746
|
+
return refuse("delivery storage refused this disposition; inspect ahub status and ask the person to resolve it");
|
|
747
|
+
}
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
633
750
|
const previous = this.journal.resolution(id);
|
|
634
751
|
if (previous) {
|
|
635
752
|
if (previous.actor !== actor || previous.action !== action || previous.reason !== cleanReason) return refuse(`delivery ${id} was already settled with a different action or reason; the person can inspect it with ahub queue show ${id}`);
|
|
@@ -684,10 +801,11 @@ export class Bus {
|
|
|
684
801
|
if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
|
|
685
802
|
this.checkRemotePublication(record.originals[0]!);
|
|
686
803
|
}
|
|
687
|
-
const
|
|
804
|
+
const explicitAcceptedDisposition = actor === `peer:${record.peer}` && record.state === "accepted" && this.peers.get(record.peer)?.acceptedSettlement === "explicit" && (action === "completed" || action === "discard");
|
|
805
|
+
const recipientConsent = actor === `peer:${record.peer}` && (record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard") || explicitAcceptedDisposition);
|
|
688
806
|
// #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
|
|
689
|
-
// discarding it. `dispatching`
|
|
690
|
-
if (
|
|
807
|
+
// discarding it. `dispatching` stays refused; `accepted` permits only its explicit channel owner's completed/discard disposition.
|
|
808
|
+
if (record.state === "dispatching" || record.state === "accepted" && !explicitAcceptedDisposition) throw new Error("active delivery cannot be resolved");
|
|
691
809
|
const queuedDiscard = record.state === "queued" && action === "discard";
|
|
692
810
|
// #1096: a `failed` row was refused before it ran and discarding it touches no queue, so a busy peer has nothing in flight to protect either.
|
|
693
811
|
const failedDiscard = record.state === "failed" && action === "discard";
|
|
@@ -703,7 +821,7 @@ export class Bus {
|
|
|
703
821
|
this.journal.transaction(() => {
|
|
704
822
|
if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
|
|
705
823
|
const current = this.journal!.get(id)!;
|
|
706
|
-
this.journal!.resolve(id, current.revision, action, reason, revision, actor);
|
|
824
|
+
this.journal!.resolve(id, current.revision, action, reason, revision, actor, explicitAcceptedDisposition);
|
|
707
825
|
if (record.state === "queued") {
|
|
708
826
|
const ids = new Set(record.originals.map((env) => env.id));
|
|
709
827
|
queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
|
|
@@ -724,6 +842,7 @@ export class Bus {
|
|
|
724
842
|
else this.prefaces.delete(record.peer);
|
|
725
843
|
throw error;
|
|
726
844
|
}
|
|
845
|
+
if (explicitAcceptedDisposition) this.activeDeliveries.delete(id);
|
|
727
846
|
this.refreshRecoveryHold(record.peer);
|
|
728
847
|
this.onQueues?.();
|
|
729
848
|
void this.drain(record.peer);
|
|
@@ -734,7 +853,7 @@ export class Bus {
|
|
|
734
853
|
const queued = this.queues.get(peer) ?? [];
|
|
735
854
|
const review = rows.filter((r) => r.state === "needs_review");
|
|
736
855
|
const needsReview = review.length;
|
|
737
|
-
const blocked = rows.find((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
|
|
856
|
+
const blocked = rows.find((r) => (r.state === "needs_review" && (!remoteId(peer) || r.originals.some(isInvocationKind))) || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
|
|
738
857
|
const liveAccepted = rows.filter((r) => r.state === "accepted" && this.activeDeliveries.get(r.id) === peer).map((r) => r.id);
|
|
739
858
|
const hold = { ...(blocked ? { heldBy: blocked.id } : {}), ...(liveAccepted.length ? { liveAccepted } : {}) };
|
|
740
859
|
const oldest = queued.reduce<number | undefined>((a, r) => a === undefined ? r.ts : Math.min(a, r.ts), undefined);
|
|
@@ -744,6 +863,7 @@ export class Bus {
|
|
|
744
863
|
manualPausedPeers(): string[] { return [...this.manualPaused].sort(); }
|
|
745
864
|
setManualPaused(ids: string[]): void { this.manualPaused.clear(); for (const id of ids) this.manualPaused.add(id); this.persist(); }
|
|
746
865
|
closeJournal(): void {
|
|
866
|
+
this.reviewParents.clear(); this.reviewResponses.clear();
|
|
747
867
|
this.closed = true;
|
|
748
868
|
this.recoveryHeld = true;
|
|
749
869
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
@@ -760,22 +880,33 @@ export class Bus {
|
|
|
760
880
|
if (this.peers.get(peer)?.remoteRoom !== undefined && receipt.state === "failed_safe") {
|
|
761
881
|
// A signed recipient NACK is a terminal known refusal, not a failed native
|
|
762
882
|
// turn eligible for poison retries. It records no recipient read evidence.
|
|
763
|
-
this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely");
|
|
764
|
-
this.activeDeliveries.delete(id);
|
|
883
|
+
this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely", undefined, "adapter refused");
|
|
884
|
+
this.activeDeliveries.delete(id);
|
|
885
|
+
if (this.remoteTransport.get(id)?.peer === peer) this.remoteTransport.delete(id);
|
|
765
886
|
for (const env of before?.originals ?? []) { this.attempts.delete(`${peer}:${env.id}`); this.remoteQueueExpires.get(peer)?.delete(env.id); }
|
|
766
887
|
const last = this.lastDelivery.get(peer);
|
|
767
888
|
if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
|
|
768
889
|
this.refreshRecoveryHold(peer);
|
|
769
|
-
for (const env of before?.originals ?? []) if (isInvocationKind(env))
|
|
890
|
+
for (const env of before?.originals ?? []) if (isInvocationKind(env)) {
|
|
891
|
+
try { this.onInvocationReceipt?.(env.id, "failed_safe"); } catch { /* a ledger observer cannot undo a durable signed refusal */ }
|
|
892
|
+
}
|
|
770
893
|
this.onQueues?.();
|
|
771
894
|
void this.drain(peer);
|
|
772
895
|
return;
|
|
773
896
|
}
|
|
774
897
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
775
|
-
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
776
|
-
if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
|
|
777
|
-
else
|
|
778
|
-
|
|
898
|
+
if (!remoteId(peer) && (state === "failed" || state === "needs_review")) this.deliveryFailed(peer);
|
|
899
|
+
if (state === "completed" && !remoteId(peer)) this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
|
|
900
|
+
else {
|
|
901
|
+
let cause: DeliveryFailureCause | undefined;
|
|
902
|
+
if (state === "failed" || state === "needs_review") {
|
|
903
|
+
cause = isDeliveryFailureCause(receipt.failureCause) ? receipt.failureCause : undefined;
|
|
904
|
+
if (!cause && state === "failed") cause = this.stateOf(peer) === "offline" ? "peer offline" : "adapter refused";
|
|
905
|
+
if (!cause) cause = "turn aborted";
|
|
906
|
+
}
|
|
907
|
+
this.journal.transition(id, state, receipt.reason, undefined, cause);
|
|
908
|
+
}
|
|
909
|
+
if (!remoteId(peer) && before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
|
|
779
910
|
this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
|
|
780
911
|
this.delivered(peer, before.originals, id);
|
|
781
912
|
}
|
|
@@ -783,20 +914,27 @@ export class Bus {
|
|
|
783
914
|
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
|
|
784
915
|
}
|
|
785
916
|
if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
|
|
786
|
-
if (state === "completed" || state === "failed" || state === "needs_review")
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
917
|
+
if (state === "completed" || state === "failed" || state === "needs_review") {
|
|
918
|
+
this.activeDeliveries.delete(id);
|
|
919
|
+
if (remoteId(peer) && this.remoteTransport.get(id)?.peer === peer) this.remoteTransport.delete(id);
|
|
920
|
+
}
|
|
921
|
+
for (const env of before?.originals ?? []) if (isInvocationKind(env) && (receipt.state === "completed" || receipt.state === "needs_review")) {
|
|
922
|
+
try { this.onInvocationReceipt?.(env.id, receipt.state); } catch { /* a ledger observer cannot undo a durable transport receipt */ }
|
|
923
|
+
}
|
|
924
|
+
if (state === "needs_review" && (!remoteId(peer) || before?.originals.some(isInvocationKind))) {
|
|
790
925
|
this.recoveryHeldPeers.add(peer);
|
|
791
|
-
for (const env of before?.originals ?? []) if (env.kind === "invocation")
|
|
926
|
+
for (const env of before?.originals ?? []) if (env.kind === "invocation") {
|
|
927
|
+
try { this.onInvocationUncertain?.(env.id); } catch { /* uncertainty hold remains durable */ }
|
|
928
|
+
}
|
|
792
929
|
}
|
|
930
|
+
if (state === "completed" || state === "failed" || state === "needs_review" && remoteId(peer)) { this.refreshRecoveryHold(peer); void this.drain(peer); }
|
|
793
931
|
this.onQueues?.();
|
|
794
932
|
}
|
|
795
933
|
|
|
796
934
|
private refreshRecoveryHold(peer: PeerId): void {
|
|
797
935
|
if (!this.journal) return;
|
|
798
936
|
// Live accepted notifications are awaiting explicit correlation, not crash recovery.
|
|
799
|
-
const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
|
|
937
|
+
const blocked = this.journal.list(peer).some((r) => (r.state === "needs_review" && (!remoteId(peer) || r.originals.some(isInvocationKind))) || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
|
|
800
938
|
if (blocked) this.recoveryHeldPeers.add(peer); else this.recoveryHeldPeers.delete(peer);
|
|
801
939
|
}
|
|
802
940
|
|
|
@@ -865,14 +1003,13 @@ export class Bus {
|
|
|
865
1003
|
}
|
|
866
1004
|
|
|
867
1005
|
pause(id: PeerId): void {
|
|
1006
|
+
// Transient holds are not in BusSnapshot; their owners persist durable policy.
|
|
868
1007
|
this.paused.add(id);
|
|
869
|
-
this.persist();
|
|
870
1008
|
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
871
1009
|
}
|
|
872
1010
|
|
|
873
1011
|
resume(id: PeerId): void {
|
|
874
1012
|
if (!this.paused.delete(id)) return;
|
|
875
|
-
this.persist();
|
|
876
1013
|
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
877
1014
|
void this.drain(id);
|
|
878
1015
|
}
|
|
@@ -1040,9 +1177,9 @@ export class Bus {
|
|
|
1040
1177
|
const index = queue.findIndex(item => item.id === env.id);
|
|
1041
1178
|
if (index >= 0) queue.splice(index, 1);
|
|
1042
1179
|
const known = this.remoteQueueExpires.get(peer)?.has(env.id);
|
|
1180
|
+
const failureCause: DeliveryFailureCause = known && this.remoteQueueRooms.has(peer) && this.remoteQueueMembers.has(peer) ? "timeout" : "push error";
|
|
1043
1181
|
this.remoteQueueExpires.get(peer)?.delete(env.id);
|
|
1044
|
-
this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "needs_review", createdAt: now, originals: [env], out: [env], reason: !known ? "remote queue expiry unavailable after restore" : !this.remoteQueueRooms.has(peer) ? "remote queue room binding unavailable after restore" : !this.remoteQueueMembers.has(peer) ? "remote queue member binding unavailable after restore" : "remote queue expired before recipient admission" });
|
|
1045
|
-
this.recoveryHeldPeers.add(peer);
|
|
1182
|
+
this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "needs_review", createdAt: now, originals: [env], out: [env], failureCause, reason: !known ? "remote queue expiry unavailable after restore" : !this.remoteQueueRooms.has(peer) ? "remote queue room binding unavailable after restore" : !this.remoteQueueMembers.has(peer) ? "remote queue member binding unavailable after restore" : "remote queue expired before recipient admission" });
|
|
1046
1183
|
}
|
|
1047
1184
|
this.persist();
|
|
1048
1185
|
} catch (error) {
|
|
@@ -1073,13 +1210,235 @@ export class Bus {
|
|
|
1073
1210
|
return true;
|
|
1074
1211
|
}
|
|
1075
1212
|
|
|
1213
|
+
/** Settle an active or held remote row only when the current room adapter owns this receipt path. */
|
|
1214
|
+
settleRemoteReceipt(source: PeerAdapter, deliveryId: string, outcome: "completed" | "failed_safe", reason: string, currentGuard: () => boolean): boolean {
|
|
1215
|
+
const peer = source.id;
|
|
1216
|
+
if ((outcome !== "completed" && outcome !== "failed_safe") || !parseRemotePeerId(peer) || this.peers.get(peer) !== source ||
|
|
1217
|
+
!source.remoteRoom || !remoteFingerprint(source.remoteMember) || !this.journal || this.storageError || this.closed) return false;
|
|
1218
|
+
if (this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
|
|
1219
|
+
const row = this.journal.get(deliveryId);
|
|
1220
|
+
if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true || this.journal.resolution(deliveryId)) return false;
|
|
1221
|
+
if (row.state === "needs_review" && row.originals.some(isInvocationKind)) return false;
|
|
1222
|
+
const activeOwner = this.activeDeliveries.get(deliveryId);
|
|
1223
|
+
if (activeOwner !== undefined && activeOwner !== peer) return false;
|
|
1224
|
+
try { if (currentGuard() !== true) return false; } catch { return false; }
|
|
1225
|
+
try {
|
|
1226
|
+
if (outcome === "completed") this.journal.completeRemoteReceipt(row.id, peer, row.revision, reason);
|
|
1227
|
+
else this.journal.failRemoteReceipt(row.id, peer, row.revision, reason);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (error instanceof Error && /stale delivery revision|already has a person disposition|not unresolved/.test(error.message)) return false;
|
|
1230
|
+
this.storageError = "delivery journal unavailable";
|
|
1231
|
+
this.onQueues?.();
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
this.activeDeliveries.delete(row.id);
|
|
1235
|
+
this.remoteTransport.delete(row.id);
|
|
1236
|
+
for (const env of row.originals) {
|
|
1237
|
+
this.attempts.delete(`${peer}:${env.id}`);
|
|
1238
|
+
this.remoteQueueExpires.get(peer)?.delete(env.id);
|
|
1239
|
+
}
|
|
1240
|
+
if (row.state !== "needs_review") for (const env of row.originals) if (isInvocationKind(env)) {
|
|
1241
|
+
try { this.onInvocationReceipt?.(env.id, outcome); } catch { /* the durable room receipt remains settled */ }
|
|
1242
|
+
}
|
|
1243
|
+
this.refreshRecoveryHold(peer);
|
|
1244
|
+
this.emit({ t: "remote_delivery_settled", peer, deliveryId: row.id, outcome });
|
|
1245
|
+
this.onQueues?.();
|
|
1246
|
+
void this.drain(peer);
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** A typed room refusal before durable queue admission is safe to retry without failure accounting. */
|
|
1251
|
+
requeueRemoteNotSent(source: PeerAdapter, deliveryId: string): boolean {
|
|
1252
|
+
const peer = source.id;
|
|
1253
|
+
if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom ||
|
|
1254
|
+
!remoteFingerprint(source.remoteMember) || !this.journal || this.storageError || this.closed ||
|
|
1255
|
+
this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember ||
|
|
1256
|
+
this.activeDeliveries.get(deliveryId) !== peer) return false;
|
|
1257
|
+
const row = this.journal.get(deliveryId);
|
|
1258
|
+
if (!row || row.peer !== peer || row.state !== "dispatching" || row.originals.length !== 1) return false;
|
|
1259
|
+
const env = row.originals[0]!;
|
|
1260
|
+
if (env.to?.includes(peer) !== true) return false;
|
|
1261
|
+
const expiry = this.remoteQueueExpires.get(peer)?.get(env.id);
|
|
1262
|
+
if (expiry === undefined || expiry <= Date.now()) {
|
|
1263
|
+
this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", failureCause: expiry === undefined ? "push error" : "timeout", reason: expiry === undefined ? "remote queue expiry unavailable after restore" : "remote queue expired before recipient admission" });
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
const queue = this.queues.get(peer) ?? [], beforeQueue = [...queue];
|
|
1267
|
+
if (queue.some(item => item.id === env.id)) return false;
|
|
1268
|
+
try {
|
|
1269
|
+
this.journal.transaction(() => {
|
|
1270
|
+
this.journal!.transition(deliveryId, "queued", "room transport confirmed not sent", row.revision);
|
|
1271
|
+
queue.unshift(env);
|
|
1272
|
+
this.queues.set(peer, queue);
|
|
1273
|
+
this.persist();
|
|
1274
|
+
});
|
|
1275
|
+
} catch {
|
|
1276
|
+
queue.splice(0, queue.length, ...beforeQueue);
|
|
1277
|
+
if (!beforeQueue.length && !this.queues.has(peer)) this.queues.delete(peer);
|
|
1278
|
+
this.storageError = "delivery journal unavailable";
|
|
1279
|
+
this.onQueues?.();
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
this.activeDeliveries.delete(deliveryId);
|
|
1283
|
+
this.remoteTransport.delete(deliveryId);
|
|
1284
|
+
this.refreshRecoveryHold(peer);
|
|
1285
|
+
this.onQueues?.();
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Host-only recovery port: expose one matching room original for proof revalidation. */
|
|
1290
|
+
remoteDeliveryEnvelope(source: PeerAdapter, deliveryId: string): Envelope | undefined {
|
|
1291
|
+
const peer = source.id;
|
|
1292
|
+
if (this.peers.get(peer) !== source || !parseRemotePeerId(peer) || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
|
|
1293
|
+
this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember ||
|
|
1294
|
+
!this.journal || this.storageError || this.closed) return undefined;
|
|
1295
|
+
const row = this.journal.get(deliveryId), original = row?.originals[0];
|
|
1296
|
+
if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || this.journal.resolution(deliveryId) || row.originals.length !== 1 || original?.to?.includes(peer) !== true) return undefined;
|
|
1297
|
+
return structuredClone(original);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** Current host admission for sending remote work, separate from immutable room trust proof. */
|
|
1301
|
+
remoteSendAllowed(source: PeerAdapter): boolean {
|
|
1302
|
+
const peer = source.id;
|
|
1303
|
+
if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
|
|
1304
|
+
!this.journal || this.storageError || this.closed || this.recoveryHeld ||
|
|
1305
|
+
this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
|
|
1306
|
+
try {
|
|
1307
|
+
return this.isRostered(peer) && !this.paused.has(peer) && !this.manualPaused.has(peer) && this.opts.deliveryHeld?.(peer) !== true;
|
|
1308
|
+
} catch { return false; }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private holdRemoteDeliveryMetadata(peer: PeerId, row: JournalDelivery, reason: string): boolean {
|
|
1312
|
+
try { this.journal!.transition(row.id, "needs_review", reason, row.revision, row.failureCause ?? "push error"); }
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
if (error instanceof Error && /stale delivery revision|unknown delivery|already has a person disposition/.test(error.message)) return false;
|
|
1315
|
+
this.storageError = "delivery journal unavailable";
|
|
1316
|
+
this.onQueues?.();
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
this.activeDeliveries.delete(row.id);
|
|
1320
|
+
if (this.remoteTransport.get(row.id)?.peer === peer) this.remoteTransport.delete(row.id);
|
|
1321
|
+
this.refreshRecoveryHold(peer);
|
|
1322
|
+
this.onQueues?.();
|
|
1323
|
+
void this.drain(peer);
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Hold a remote row for human inspection when its durable intent or delivery proof is unavailable. */
|
|
1328
|
+
holdRemoteMissingIntent(source: PeerAdapter, deliveryId: string, currentGuard: () => boolean, origin: "restored" | "known-not-sent" | "proof-unavailable" = "restored"): boolean {
|
|
1329
|
+
if (origin !== "restored" && origin !== "known-not-sent" && origin !== "proof-unavailable") return false;
|
|
1330
|
+
const peer = source.id;
|
|
1331
|
+
if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
|
|
1332
|
+
!this.journal || this.storageError || this.closed ||
|
|
1333
|
+
this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
|
|
1334
|
+
const row = this.journal.get(deliveryId);
|
|
1335
|
+
if (!row || row.peer !== peer || !["dispatching", "accepted", ...(origin === "proof-unavailable" ? ["needs_review"] : [])].includes(row.state) || row.originals.length !== 1 ||
|
|
1336
|
+
row.originals[0]?.to?.includes(peer) !== true || this.journal.resolution(deliveryId)) return false;
|
|
1337
|
+
if (origin === "proof-unavailable" && row.originals.some(isInvocationKind)) return false;
|
|
1338
|
+
if (origin === "known-not-sent") {
|
|
1339
|
+
if (this.activeDeliveries.get(deliveryId) !== peer) return false;
|
|
1340
|
+
} else if (origin === "restored") {
|
|
1341
|
+
if (this.activeDeliveries.has(deliveryId) || this.remoteTransport.has(deliveryId)) return false;
|
|
1342
|
+
} else {
|
|
1343
|
+
const activeOwner = this.activeDeliveries.get(deliveryId), transportOwner = this.remoteTransport.get(deliveryId)?.peer;
|
|
1344
|
+
if (activeOwner !== undefined && activeOwner !== peer || transportOwner !== undefined && transportOwner !== peer) return false;
|
|
1345
|
+
}
|
|
1346
|
+
try { if (currentGuard() !== true) return false; } catch { return false; }
|
|
1347
|
+
if (origin === "proof-unavailable" && row.state === "needs_review") return true;
|
|
1348
|
+
const reason = origin === "restored"
|
|
1349
|
+
? `room durable intent unavailable after restart; inspect ahub queue show ${deliveryId}`
|
|
1350
|
+
: origin === "known-not-sent"
|
|
1351
|
+
? `room not-sent checkpoint unavailable; inspect ahub queue show ${deliveryId}`
|
|
1352
|
+
: `${ROOM_PROOF_UNAVAILABLE_REASON} ${deliveryId}`;
|
|
1353
|
+
return this.holdRemoteDeliveryMetadata(peer, row, reason);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Host-only quarantine when a signed room outcome belongs to an invalidated recipient tuple. */
|
|
1357
|
+
holdInvalidatedRemoteOutcome(peerId: string, deliveryId: string, expectedRoom: string, expectedMember: string, currentGuard: () => boolean): boolean {
|
|
1358
|
+
// Exact equality below inherits the room bound enforced at add/loadSnapshot.
|
|
1359
|
+
if (!parseRemotePeerId(peerId) || typeof expectedRoom !== "string" || !expectedRoom ||
|
|
1360
|
+
!remoteFingerprint(expectedMember) || !this.journal || this.storageError || this.closed ||
|
|
1361
|
+
this.remoteQueueRooms.get(peerId) !== expectedRoom || this.remoteQueueMembers.get(peerId) !== expectedMember) return false;
|
|
1362
|
+
const row = this.journal.get(deliveryId);
|
|
1363
|
+
if (!row || row.peer !== peerId || !["dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 ||
|
|
1364
|
+
row.originals[0]?.to?.includes(peerId) !== true || this.journal.resolution(deliveryId) ||
|
|
1365
|
+
this.activeDeliveries.has(deliveryId) || this.remoteTransport.has(deliveryId)) return false;
|
|
1366
|
+
try { if (currentGuard() !== true) return false; } catch { return false; }
|
|
1367
|
+
return this.holdRemoteDeliveryMetadata(peerId, row, `room recipient proof no longer current after restart; inspect ahub queue show ${deliveryId}`);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Resume the same delivery after the host revalidates its durable outbox proof. */
|
|
1371
|
+
resumeRemotePending(source: PeerAdapter, deliveryId: string, state: "dispatching" | "accepted" | "needs_review", currentGuard: () => boolean): boolean {
|
|
1372
|
+
const peer = source.id;
|
|
1373
|
+
const journal = this.journal;
|
|
1374
|
+
if (!journal || !this.remoteSendAllowed(source)) return false;
|
|
1375
|
+
const row = journal.get(deliveryId), env = row?.originals[0], expiry = env && this.remoteQueueExpires.get(peer)?.get(env.id);
|
|
1376
|
+
if (!row || row.peer !== peer || !["dispatching", "accepted", "needs_review"].includes(row.state) || row.originals.length !== 1 || env?.to?.includes(peer) !== true || journal.resolution(deliveryId) || !env || expiry === undefined || expiry <= Date.now()) return false;
|
|
1377
|
+
if (row.state === "needs_review" && row.originals.some(isInvocationKind)) return false;
|
|
1378
|
+
if (state === "needs_review") return false;
|
|
1379
|
+
if ([...(this.queues.get(peer) ?? [])].some(item => item.id === env.id)) return false;
|
|
1380
|
+
if ([...this.activeDeliveries].some(([id, owner]) => owner === peer && id !== deliveryId)) return false;
|
|
1381
|
+
if ([...this.remoteTransport].some(([id, active]) => active.peer === peer && id !== deliveryId)) return false;
|
|
1382
|
+
try { if (currentGuard() !== true) return false; } catch { return false; }
|
|
1383
|
+
try {
|
|
1384
|
+
// Store and journal commit independently. An already-active journal row is
|
|
1385
|
+
// authoritative; a different Store state must not strand or downgrade it.
|
|
1386
|
+
if (row.state === "needs_review") journal.transition(deliveryId, state, "room outbox proof confirmed pending", row.revision);
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
this.storageError = "delivery journal unavailable";
|
|
1390
|
+
this.onQueues?.();
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
this.activeDeliveries.set(deliveryId, peer);
|
|
1394
|
+
this.remoteTransport.set(deliveryId, { peer, originals: row.originals });
|
|
1395
|
+
this.refreshRecoveryHold(peer);
|
|
1396
|
+
this.onQueues?.();
|
|
1397
|
+
void this.drain(peer);
|
|
1398
|
+
return true;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1076
1401
|
/** Returns the peers the envelope was accepted for, including durable deferred notices. */
|
|
1077
1402
|
publish(env: Envelope): PeerId[] {
|
|
1078
1403
|
return this.publishResult(env).targets;
|
|
1079
1404
|
}
|
|
1080
1405
|
|
|
1406
|
+
private clearReviewProvenance(peer: PeerId): void {
|
|
1407
|
+
this.reviewParents.delete(peer);
|
|
1408
|
+
for (const [id, source] of this.reviewResponses) if (source.peer === peer) this.reviewResponses.delete(id);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Host-derived observer provenance. Caller-supplied response/package markers are stripped.
|
|
1412
|
+
* ponytail: active turns and 128 delivered parent IDs per peer are attributable; arbitrary later
|
|
1413
|
+
* native-session paraphrases need explicit session provenance, never content matching or PII authority.
|
|
1414
|
+
*/
|
|
1415
|
+
reviewResponse(env: Envelope, parentId?: string): Envelope {
|
|
1416
|
+
if (env.from === HUB) return env;
|
|
1417
|
+
const pulled = this.lastPulled.get(env.from)?.originals ?? [], pushed = this.lastDelivery.get(env.from)?.originals ?? [];
|
|
1418
|
+
const active = pulled.length ? pulled : pushed;
|
|
1419
|
+
const parent = parentId
|
|
1420
|
+
? pulled.find(item => item.id === parentId && isReviewObserverExcluded(item)) ?? pushed.find(item => item.id === parentId && isReviewObserverExcluded(item))
|
|
1421
|
+
: active.find(isReviewObserverExcluded);
|
|
1422
|
+
const known = this.reviewResponses.get(env.id);
|
|
1423
|
+
let durableScope: string | undefined;
|
|
1424
|
+
try { durableScope = this.opts.reviewObserverScope?.(env.from); }
|
|
1425
|
+
catch { durableScope = "review-scope-unavailable"; } // Unknown provenance cannot clear an observer exclusion.
|
|
1426
|
+
const source = parent?.refs?.reviewPackage ?? parent?.refs?.reviewResponse
|
|
1427
|
+
?? (parentId ? this.reviewParents.get(env.from)?.get(parentId) : undefined)
|
|
1428
|
+
?? (known?.peer === env.from ? known.source : undefined) ?? durableScope;
|
|
1429
|
+
const { reviewPackage: _package, reviewResponse: _response, ...refs } = env.refs ?? {};
|
|
1430
|
+
if (source && (env.kind === "chat" || env.kind === "status")) {
|
|
1431
|
+
this.reviewResponses.set(env.id, { peer: env.from, source });
|
|
1432
|
+
if (this.reviewResponses.size > SEEN_CAP) this.reviewResponses.delete(this.reviewResponses.keys().next().value!);
|
|
1433
|
+
return { ...env, refs: { ...refs, reviewResponse: source } };
|
|
1434
|
+
}
|
|
1435
|
+
return _package !== undefined || _response !== undefined ? { ...env, refs } : env;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1081
1438
|
/** Publication outcome distinguishes a policy drop from an empty audience. */
|
|
1082
1439
|
publishResult(env: Envelope): PublishResult {
|
|
1440
|
+
if (this.closed) return { targets: [] };
|
|
1441
|
+
env = this.reviewResponse(env);
|
|
1083
1442
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
1084
1443
|
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
|
|
1085
1444
|
this.checkRemotePublication(env);
|
|
@@ -1096,6 +1455,7 @@ export class Bus {
|
|
|
1096
1455
|
}
|
|
1097
1456
|
|
|
1098
1457
|
private publishNow(env: Envelope): PublishResult {
|
|
1458
|
+
if (this.closed) return { targets: [] };
|
|
1099
1459
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
1100
1460
|
if (this.seen.has(env.id)) return { targets: [] };
|
|
1101
1461
|
const rooms = this.checkRemotePublication(env);
|
|
@@ -1118,14 +1478,17 @@ export class Bus {
|
|
|
1118
1478
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
1119
1479
|
if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy" && !this.invocationTurnOpen(id)) {
|
|
1120
1480
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
1481
|
+
try { this.beforeReviewInputHandoff(id, [env]); }
|
|
1482
|
+
catch { this.enqueue(id, env); continue; }
|
|
1121
1483
|
this.steering++;
|
|
1122
1484
|
const deliveryId = crypto.randomUUID();
|
|
1123
1485
|
this.activeDeliveries.set(deliveryId, id);
|
|
1124
1486
|
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
1487
|
+
this.noteSteering(id, [env]);
|
|
1125
1488
|
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
1126
1489
|
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
1127
1490
|
this.deliveryFailed(id);
|
|
1128
|
-
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
1491
|
+
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain", undefined, "push error");
|
|
1129
1492
|
this.activeDeliveries.delete(deliveryId);
|
|
1130
1493
|
if (!this.journal) this.enqueue(id, env, true);
|
|
1131
1494
|
}).finally(() => { this.steering--; });
|
|
@@ -1179,6 +1542,45 @@ export class Bus {
|
|
|
1179
1542
|
get(id: string): Envelope | undefined {
|
|
1180
1543
|
return this.seen.get(id) ?? this.deferredHub.get(id);
|
|
1181
1544
|
}
|
|
1545
|
+
/** Only newly saved versioned review packages may interpret absent as permission for explicit publication. */
|
|
1546
|
+
reviewEnvelopeStatus(id: string): "published" | "absent" | "unknown" | "failed" | "discarded" {
|
|
1547
|
+
if (this.closed || this.storageError || !this.journal) return "unknown";
|
|
1548
|
+
try {
|
|
1549
|
+
const state = this.journal.reviewEnvelopeState(id);
|
|
1550
|
+
if (state === "failed" || state === "discarded") return state;
|
|
1551
|
+
return state ? "published" : "absent";
|
|
1552
|
+
}
|
|
1553
|
+
catch { return "unknown"; }
|
|
1554
|
+
}
|
|
1555
|
+
hasUnsettledReviewScope(peer: PeerId, task?: number): boolean {
|
|
1556
|
+
if (this.closed || this.storageError) return true;
|
|
1557
|
+
if (!this.journal) return false; // Ephemeral test buses retain the existing delivered-parent scope.
|
|
1558
|
+
try {
|
|
1559
|
+
const revision = this.journal.revision();
|
|
1560
|
+
if (revision !== this.reviewScopeRevision) { this.reviewScopeCache.clear(); this.reviewScopeRevision = revision; }
|
|
1561
|
+
const key = JSON.stringify([peer, task ?? null]), cached = this.reviewScopeCache.get(key);
|
|
1562
|
+
if (cached !== undefined) return cached;
|
|
1563
|
+
const held = this.journal.hasUnsettledReviewScope(peer, task);
|
|
1564
|
+
this.reviewScopeCache.set(key, held);
|
|
1565
|
+
if (this.reviewScopeCache.size > 512) this.reviewScopeCache.delete(this.reviewScopeCache.keys().next().value!);
|
|
1566
|
+
return held;
|
|
1567
|
+
} catch { return true; }
|
|
1568
|
+
}
|
|
1569
|
+
/** Explicit recovery reuses the immutable package envelope; a durable publication is never replayed. */
|
|
1570
|
+
publishReviewEnvelope(env: Envelope): PeerId[] {
|
|
1571
|
+
if (env.from !== HUB || env.kind !== "review" || !env.refs?.reviewPackage || !env.to?.length) throw new Error("review package envelope is invalid; refresh the saved package before retrying");
|
|
1572
|
+
const status = this.reviewEnvelopeStatus(env.id);
|
|
1573
|
+
if (status === "published") {
|
|
1574
|
+
this.journal!.reviewEnvelopePublished(env.id, env);
|
|
1575
|
+
return [...env.to];
|
|
1576
|
+
}
|
|
1577
|
+
if (status === "failed" || status === "discarded")
|
|
1578
|
+
throw new Error(`saved review package delivery is ${status}; inspect ahub queue list --peer ${env.to[0]} and ahub queue show <delivery-id> before retrying`);
|
|
1579
|
+
if (status !== "absent") throw new Error("review package publication evidence is unavailable; inspect ahub queue list before retrying");
|
|
1580
|
+
// A pre-checkpoint observer may have thrown after remembering the id but before any durable enqueue or native effect.
|
|
1581
|
+
this.seen.delete(env.id);
|
|
1582
|
+
return this.publish(env);
|
|
1583
|
+
}
|
|
1182
1584
|
|
|
1183
1585
|
/**
|
|
1184
1586
|
* #445 C2: whether the peer's current turn is one that holds an invocation request. With a journal that is a delivery of the peer's own that has not reached a
|
|
@@ -1187,7 +1589,7 @@ export class Bus {
|
|
|
1187
1589
|
* everything its turn holds, and a steered one would become what the answer names instead of the request.
|
|
1188
1590
|
*/
|
|
1189
1591
|
private invocationTurnOpen(peer: PeerId): boolean {
|
|
1190
|
-
if (!this.journal) return this.stateOf(peer) === "busy" && (this.lastDelivery.get(peer)?.out ?? []).some((e) => e.kind === "invocation");
|
|
1592
|
+
if (!this.journal) return this.stateOf(peer) === "busy" && [...(this.lastDelivery.get(peer)?.out ?? []), ...(this.lastPulled.get(peer)?.out ?? [])].some((e) => e.kind === "invocation");
|
|
1191
1593
|
for (const [deliveryId, owner] of this.activeDeliveries) {
|
|
1192
1594
|
if (owner === peer && this.journal.get(deliveryId)?.out.some((e) => e.kind === "invocation")) return true;
|
|
1193
1595
|
}
|
|
@@ -1196,9 +1598,12 @@ export class Bus {
|
|
|
1196
1598
|
|
|
1197
1599
|
/** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
|
|
1198
1600
|
lastDeliveredTo(peer: PeerId): Envelope[] {
|
|
1199
|
-
return this.lastDelivery.get(peer)?.out ?? [];
|
|
1601
|
+
return this.lastPulled.get(peer)?.out ?? this.lastDelivery.get(peer)?.out ?? [];
|
|
1200
1602
|
}
|
|
1201
1603
|
|
|
1604
|
+
/** A confirmed new native session invalidates only this session's current-input cache; durable named-parent rows remain. */
|
|
1605
|
+
resetLastDelivery(peer: PeerId): void { this.lastDelivery.delete(peer); this.lastPulled.delete(peer); }
|
|
1606
|
+
|
|
1202
1607
|
/**
|
|
1203
1608
|
* Take back an envelope that has not been delivered yet (a request that has expired). True when it was removed from a
|
|
1204
1609
|
* queue now; an envelope whose steer is still in flight is not in any queue, so its id is remembered and it is
|
|
@@ -1254,11 +1659,21 @@ export class Bus {
|
|
|
1254
1659
|
}
|
|
1255
1660
|
|
|
1256
1661
|
private memoryDelivery(peer: PeerId, id: string, out: Envelope[], field: "rendered" | "delivered", originals: Envelope[] = out): void {
|
|
1257
|
-
if (remoteId(peer) || [...out, ...originals].some(env => env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
|
|
1662
|
+
if (remoteId(peer) || this.opts.reviewObserverInputExcluded?.(peer, originals) === true || [...out, ...originals].some(env => isReviewObserverExcluded(env) || env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
|
|
1258
1663
|
try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
|
|
1259
1664
|
}
|
|
1260
1665
|
|
|
1666
|
+
private rememberReviewParents(peer: PeerId, originals: Envelope[], requireAttached = true): void {
|
|
1667
|
+
for (const env of originals) if ((!requireAttached || !this.closed && this.peers.has(peer) && this.stateOf(peer) !== "offline") && isReviewObserverExcluded(env)) {
|
|
1668
|
+
const parents = this.reviewParents.get(peer) ?? new Map<string, string>();
|
|
1669
|
+
parents.set(env.id, (env.refs?.reviewPackage ?? env.refs?.reviewResponse)!);
|
|
1670
|
+
if (parents.size > 128) parents.delete(parents.keys().next().value!);
|
|
1671
|
+
this.reviewParents.set(peer, parents);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1261
1675
|
private delivered(peer: PeerId, originals: Envelope[], deliveryId?: string): void {
|
|
1676
|
+
this.rememberReviewParents(peer, originals);
|
|
1262
1677
|
try { this.onDelivered?.(peer, originals, deliveryId); } catch { /* observation must never affect delivery settlement */ }
|
|
1263
1678
|
}
|
|
1264
1679
|
|
|
@@ -1266,6 +1681,15 @@ export class Bus {
|
|
|
1266
1681
|
try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
|
|
1267
1682
|
}
|
|
1268
1683
|
|
|
1684
|
+
private noteSteering(peer: PeerId, originals: Envelope[]): void {
|
|
1685
|
+
try { this.onSteering?.(peer, originals); } catch { /* metadata observation cannot alter the steer attempt */ }
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private beforeReviewInputHandoff(peer: PeerId, originals: Envelope[]): void {
|
|
1689
|
+
try { this.opts.beforeReviewInputHandoff?.(peer, originals); }
|
|
1690
|
+
catch { throw new ReviewInputHandoffError(); }
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1269
1693
|
private markWithdrawn(id: string): void {
|
|
1270
1694
|
this.withdrawn.add(id);
|
|
1271
1695
|
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
@@ -1380,7 +1804,7 @@ export class Bus {
|
|
|
1380
1804
|
if (!remoteId(id) && queue.length > this.opts.queueCap) {
|
|
1381
1805
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
1382
1806
|
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
1383
|
-
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
|
|
1807
|
+
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded", failureCause: "adapter refused" });
|
|
1384
1808
|
this.emit({ t: "overflow", env: lost!, peer: id });
|
|
1385
1809
|
}
|
|
1386
1810
|
if (remoteId(id) && !front) {
|
|
@@ -1410,9 +1834,9 @@ export class Bus {
|
|
|
1410
1834
|
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
1411
1835
|
}
|
|
1412
1836
|
|
|
1413
|
-
private uncertain(peer: PeerId, deliveryId: string, reason: string): void {
|
|
1837
|
+
private uncertain(peer: PeerId, deliveryId: string, reason: string, failureCause: DeliveryFailureCause = "push error"): void {
|
|
1414
1838
|
const row = this.journal?.get(deliveryId);
|
|
1415
|
-
if (row && ["dispatching", "accepted"].includes(row.state)) this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", reason });
|
|
1839
|
+
if (row && ["dispatching", "accepted"].includes(row.state)) this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", reason, failureCause });
|
|
1416
1840
|
}
|
|
1417
1841
|
|
|
1418
1842
|
private async steerQueued(id: PeerId, env: Envelope): Promise<void> {
|
|
@@ -1428,16 +1852,18 @@ export class Bus {
|
|
|
1428
1852
|
let removed = false, handedOff = false;
|
|
1429
1853
|
try {
|
|
1430
1854
|
const out = this.retryOut(id, [env], [env]);
|
|
1855
|
+
this.beforeReviewInputHandoff(id, [env]);
|
|
1431
1856
|
queue.splice(index, 1);
|
|
1432
1857
|
removed = true;
|
|
1433
1858
|
this.durableHandoff(id, deliveryId, [env], out);
|
|
1434
1859
|
handedOff = true;
|
|
1435
1860
|
this.activeDeliveries.set(deliveryId, id);
|
|
1861
|
+
this.noteSteering(id, [env]);
|
|
1436
1862
|
try { await peer.steer(out, deliveryId); }
|
|
1437
1863
|
catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
|
|
1438
|
-
} catch {
|
|
1864
|
+
} catch (error) {
|
|
1439
1865
|
if (removed && !handedOff) queue.splice(index, 0, env);
|
|
1440
|
-
this.storageError = "delivery journal unavailable";
|
|
1866
|
+
if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable";
|
|
1441
1867
|
} finally { this.steering--; this.onQueues?.(); }
|
|
1442
1868
|
}
|
|
1443
1869
|
|
|
@@ -1488,6 +1914,7 @@ export class Bus {
|
|
|
1488
1914
|
const deliveryId = crypto.randomUUID();
|
|
1489
1915
|
try {
|
|
1490
1916
|
out = this.retryOut(id, batch, batch);
|
|
1917
|
+
this.beforeReviewInputHandoff(id, batch);
|
|
1491
1918
|
this.prefaces.delete(id);
|
|
1492
1919
|
// Before the checkpoint, as in a drain, so the persisted snapshot carries it; a failed policy read rolls back too.
|
|
1493
1920
|
this.remember(out);
|
|
@@ -1498,15 +1925,18 @@ export class Bus {
|
|
|
1498
1925
|
} catch (error) {
|
|
1499
1926
|
queue.splice(0, queue.length, ...queued);
|
|
1500
1927
|
if (preface) this.prefaces.set(id, preface);
|
|
1501
|
-
this.storageError = "delivery journal unavailable";
|
|
1928
|
+
if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable";
|
|
1502
1929
|
throw error;
|
|
1503
1930
|
}
|
|
1504
1931
|
if (this.journal) {
|
|
1505
1932
|
this.memoryDelivery(id, deliveryId, out, "rendered", batch);
|
|
1506
1933
|
this.memoryDelivery(id, deliveryId, out, "delivered", batch);
|
|
1507
1934
|
}
|
|
1935
|
+
this.lastPulled.set(id, { out, originals: batch });
|
|
1936
|
+
this.rememberReviewParents(id, batch, false);
|
|
1937
|
+
if (batch.length) { try { this.onPulled?.(id, batch); } catch { /* metadata observation cannot change a committed inbox result */ } }
|
|
1508
1938
|
this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
|
|
1509
|
-
this.onQueues?.();
|
|
1939
|
+
try { this.onQueues?.(); } catch { /* a committed inbox result must still reach its caller */ }
|
|
1510
1940
|
return out;
|
|
1511
1941
|
}
|
|
1512
1942
|
|
|
@@ -1558,6 +1988,7 @@ export class Bus {
|
|
|
1558
1988
|
const deliveryId = crypto.randomUUID();
|
|
1559
1989
|
if (this.journal) {
|
|
1560
1990
|
if ((!remoteId(id) && this.prefaces.get(id) !== preface) || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
|
|
1991
|
+
this.beforeReviewInputHandoff(id, delivery);
|
|
1561
1992
|
const queued = [...queue], seen = [...this.seen];
|
|
1562
1993
|
for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
|
|
1563
1994
|
this.prefaces.delete(id);
|
|
@@ -1571,7 +2002,16 @@ export class Bus {
|
|
|
1571
2002
|
throw error;
|
|
1572
2003
|
}
|
|
1573
2004
|
this.activeDeliveries.set(deliveryId, id);
|
|
1574
|
-
} else
|
|
2005
|
+
} else {
|
|
2006
|
+
try { this.beforeReviewInputHandoff(id, delivery); }
|
|
2007
|
+
catch (error) {
|
|
2008
|
+
if (preface) this.restorePreface(id, preface);
|
|
2009
|
+
queue.unshift(...batch.filter(e => !this.withdrawn.has(e.id)));
|
|
2010
|
+
throw error;
|
|
2011
|
+
}
|
|
2012
|
+
this.remember(out);
|
|
2013
|
+
}
|
|
2014
|
+
this.lastPulled.delete(id);
|
|
1575
2015
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
1576
2016
|
for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
|
|
1577
2017
|
if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
|
|
@@ -1594,7 +2034,7 @@ export class Bus {
|
|
|
1594
2034
|
break;
|
|
1595
2035
|
} finally { this.remoteTransport.delete(deliveryId); }
|
|
1596
2036
|
}
|
|
1597
|
-
} catch { this.storageError = "delivery journal unavailable"; }
|
|
2037
|
+
} catch (error) { if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable"; }
|
|
1598
2038
|
finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
|
|
1599
2039
|
}
|
|
1600
2040
|
|