@staix/agent-hub 0.20.0 → 0.21.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 +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/hub/bus.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { appendNote, DIGEST, frame, 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 type { DoingKind } from "./peer-doing.ts";
|
|
4
|
+
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
3
5
|
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
6
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
5
7
|
import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
8
|
+
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
6
9
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
10
|
|
|
8
11
|
export type { DeliveryReceipt } from "./peers.ts";
|
|
@@ -22,6 +25,10 @@ export type BusEvent =
|
|
|
22
25
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
23
26
|
|
|
24
27
|
export interface BusOptions {
|
|
28
|
+
/** Durable application outbox ids; the bus combines them with its own pending ids without recursion. */
|
|
29
|
+
remotePending?: (room: string) => readonly string[];
|
|
30
|
+
/** Host provenance at enqueue, never a body screen; unknown/throw marks the original private. */
|
|
31
|
+
remotePrivate?: (env: Readonly<Envelope>) => boolean | undefined;
|
|
25
32
|
/** Live project membership; absent preserves the existing audience and delivery behaviour. */
|
|
26
33
|
rostered?: (peer: PeerId) => boolean;
|
|
27
34
|
/** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
|
|
@@ -37,6 +44,8 @@ export interface BusOptions {
|
|
|
37
44
|
/** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
|
|
38
45
|
condense?: (envs: Envelope[]) => Promise<Envelope[]>;
|
|
39
46
|
journal?: DeliveryJournal;
|
|
47
|
+
/** Host-owned opt-in; no delivery body or timestamp is a routing-policy authority. */
|
|
48
|
+
trackQueuePressure?: () => boolean;
|
|
40
49
|
/**
|
|
41
50
|
* Optional: may an agent send this envelope now (issue #38)? `parent` is the id of what it answers. A reason refuses
|
|
42
51
|
* it, and the sender hears it on its next delivery.
|
|
@@ -59,6 +68,8 @@ export interface BusOptions {
|
|
|
59
68
|
* dequeued, steered or journaled for it, and the queue still waits for `Bus.pull`; a throw is swallowed.
|
|
60
69
|
*/
|
|
61
70
|
onImportantQueued?: (peer: PeerId, counts: { queued: number; important: number }) => void;
|
|
71
|
+
/** Optional (#878): an attached adapter observed its session title or current plan item. Never published, journaled or tapped. */
|
|
72
|
+
doing?: (peer: PeerId, kind: DoingKind, text?: unknown) => void;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
|
|
@@ -70,8 +81,16 @@ export interface BusSnapshot {
|
|
|
70
81
|
attempts: Record<string, number>;
|
|
71
82
|
withdrawn: string[];
|
|
72
83
|
manualPaused?: string[];
|
|
84
|
+
/** Host enqueue provenance only. null means an age lost to a legacy/restored writer. */
|
|
85
|
+
queuedAt?: Record<string, Record<string, number | null>>;
|
|
73
86
|
/** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
|
|
74
87
|
deferredHub?: Envelope[];
|
|
88
|
+
/** Local enqueue deadlines; absent old metadata is unknown, never a fresh TTL. */
|
|
89
|
+
remoteQueueExpires?: Record<string, Record<string, number>>;
|
|
90
|
+
/** Host room binding for retained queues, never reconstructed from a remote label. */
|
|
91
|
+
remoteQueueRooms?: Record<string, string>;
|
|
92
|
+
/** Original verified target identity; a reused room label cannot inherit queued text. */
|
|
93
|
+
remoteQueueMembers?: Record<string, string>;
|
|
75
94
|
journal?: ReturnType<DeliveryJournal["snapshot"]>;
|
|
76
95
|
}
|
|
77
96
|
|
|
@@ -100,6 +119,12 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
100
119
|
|
|
101
120
|
const SEEN_CAP = 2048;
|
|
102
121
|
const MAX_ATTEMPTS = 3;
|
|
122
|
+
// ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
|
|
123
|
+
// Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
|
|
124
|
+
const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
|
|
125
|
+
const REMOTE_ROOM_CAP = 200, REMOTE_QUEUE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
126
|
+
const remoteId = (id: string): boolean => id.includes("@");
|
|
127
|
+
const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
103
128
|
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
104
129
|
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
105
130
|
const DIGEST_MAX = 10;
|
|
@@ -118,7 +143,15 @@ export class Bus {
|
|
|
118
143
|
private readonly opts: BusOptions;
|
|
119
144
|
private readonly journal?: DeliveryJournal;
|
|
120
145
|
private readonly queues = new Map<PeerId, Envelope[]>();
|
|
146
|
+
private readonly queueTimes = new Map<PeerId, Map<string, number | null>>();
|
|
147
|
+
private queueTimeEntries = 0;
|
|
148
|
+
private queuePressureWasTracked = false;
|
|
121
149
|
private readonly prefaces = new Map<PeerId, Envelope>();
|
|
150
|
+
private readonly remoteQueueMembers = new Map<PeerId, string>();
|
|
151
|
+
private readonly remoteQueueRooms = new Map<PeerId, string>();
|
|
152
|
+
private readonly remoteQueueExpires = new Map<PeerId, Map<string, number>>();
|
|
153
|
+
private readonly remoteReservations = new Map<string, Set<string>>();
|
|
154
|
+
private readonly remoteTransport = new Map<string, { peer: string; originals: Envelope[] }>();
|
|
122
155
|
private readonly paused = new Set<PeerId>();
|
|
123
156
|
private readonly manualPaused = new Set<PeerId>();
|
|
124
157
|
private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
@@ -168,6 +201,7 @@ export class Bus {
|
|
|
168
201
|
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
169
202
|
this.onDeliver = opts.onDeliver;
|
|
170
203
|
this.journal = opts.journal;
|
|
204
|
+
this.trackingQueuePressure();
|
|
171
205
|
if (this.journal) {
|
|
172
206
|
const state = this.journal.snapshot();
|
|
173
207
|
const bus = state.bus as BusSnapshot;
|
|
@@ -191,9 +225,22 @@ export class Bus {
|
|
|
191
225
|
}
|
|
192
226
|
|
|
193
227
|
add(peer: PeerAdapter): void {
|
|
228
|
+
if (peer.remoteRoom !== undefined && (!parseRemotePeerId(peer.id) || typeof peer.remoteRoom !== "string" || !peer.remoteRoom || peer.remoteRoom.length > 256 || !remoteFingerprint(peer.remoteMember))) throw new Error("invalid remote peer binding");
|
|
229
|
+
const pendingRemote = peer.remoteRoom !== undefined && this.hasRemoteWork(peer.id);
|
|
230
|
+
if (peer.remoteRoom !== undefined && pendingRemote) {
|
|
231
|
+
const room = this.remoteQueueRooms.get(peer.id), member = this.remoteQueueMembers.get(peer.id);
|
|
232
|
+
if (room === undefined || member === undefined) {
|
|
233
|
+
this.expireRemoteQueues();
|
|
234
|
+
if (this.hasRemoteWork(peer.id)) throw new Error("remote queued identity binding unavailable");
|
|
235
|
+
} else if (room !== peer.remoteRoom) throw new Error("remote queued room binding changed");
|
|
236
|
+
else if (member !== peer.remoteMember) throw new Error("remote queued member binding changed");
|
|
237
|
+
}
|
|
238
|
+
if (peer.remoteRoom !== undefined && !pendingRemote) { this.remoteQueueRooms.delete(peer.id); this.remoteQueueMembers.delete(peer.id); }
|
|
194
239
|
this.peers.set(peer.id, peer);
|
|
195
240
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
196
241
|
peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
|
|
242
|
+
peer.onDoing = (kind, text) => { if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
|
|
243
|
+
this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
|
|
197
244
|
peer.onMessage = (text, opts) => {
|
|
198
245
|
if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
|
|
199
246
|
const { priority, body } = parseMarker(text);
|
|
@@ -208,6 +255,12 @@ export class Bus {
|
|
|
208
255
|
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
209
256
|
const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
|
|
210
257
|
let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
|
|
258
|
+
try { this.checkRemotePublication(env); }
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
261
|
+
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${error.message}`));
|
|
262
|
+
return error.message;
|
|
263
|
+
}
|
|
211
264
|
const offRoster = this.offRosterRecipients(env);
|
|
212
265
|
if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) {
|
|
213
266
|
const reason = `off roster: ${offRoster.join(", ")}`;
|
|
@@ -233,7 +286,13 @@ export class Bus {
|
|
|
233
286
|
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${refused}`));
|
|
234
287
|
return refused;
|
|
235
288
|
}
|
|
236
|
-
|
|
289
|
+
let published: PublishResult;
|
|
290
|
+
try { published = this.publishResult(env); }
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
293
|
+
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${error.message}`));
|
|
294
|
+
return error.message;
|
|
295
|
+
}
|
|
237
296
|
if (offRoster.length && published.dropped !== "hop") this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
|
|
238
297
|
// The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
|
|
239
298
|
// not sent only when nobody got it.
|
|
@@ -267,10 +326,11 @@ export class Bus {
|
|
|
267
326
|
|
|
268
327
|
private persist(): void {
|
|
269
328
|
if (!this.journal) return;
|
|
329
|
+
const tracking = this.trackingQueuePressure();
|
|
270
330
|
try {
|
|
271
331
|
this.journal.transaction(() => {
|
|
272
332
|
for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
|
|
273
|
-
this.journal!.persistBus(this.snapshotWithoutJournal(), [...this.manualPaused]);
|
|
333
|
+
this.journal!.persistBus(this.snapshotWithoutJournal(), [...this.manualPaused], tracking);
|
|
274
334
|
});
|
|
275
335
|
this.pendingOutcomes.length = 0;
|
|
276
336
|
this.storageError = undefined;
|
|
@@ -281,7 +341,7 @@ export class Bus {
|
|
|
281
341
|
private durableHandoff(peer: PeerId, deliveryId: string, originals: Envelope[], out: Envelope[], observe = true): void {
|
|
282
342
|
if (!this.journal) return;
|
|
283
343
|
const delivery = { id: deliveryId, peer, state: "dispatching" as const, createdAt: Date.now(), originals, out };
|
|
284
|
-
this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery);
|
|
344
|
+
this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery, this.trackingQueuePressure());
|
|
285
345
|
if (observe) this.memoryDelivery(peer, deliveryId, out, "rendered", originals);
|
|
286
346
|
}
|
|
287
347
|
|
|
@@ -375,16 +435,39 @@ export class Bus {
|
|
|
375
435
|
|
|
376
436
|
/** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
|
|
377
437
|
snapshot(includeJournal = true): BusSnapshot {
|
|
438
|
+
const rooms = this.remoteRoomSnapshot(), members = this.remoteMemberSnapshot();
|
|
439
|
+
const queues = Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))]));
|
|
440
|
+
for (const peer of new Set([...Object.keys(rooms), ...Object.keys(members)])) queues[peer] ??= [];
|
|
378
441
|
const out: BusSnapshot = {
|
|
379
442
|
schemaVersion: 1,
|
|
380
|
-
queues
|
|
443
|
+
queues,
|
|
381
444
|
prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
|
|
382
445
|
seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
|
|
383
446
|
attempts: Object.fromEntries(this.attempts),
|
|
384
447
|
withdrawn: [...this.withdrawn],
|
|
385
448
|
manualPaused: [...this.manualPaused],
|
|
386
449
|
...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
|
|
450
|
+
...(this.remoteQueueExpires.size ? { remoteQueueExpires: this.remoteExpirySnapshot() } : {}),
|
|
451
|
+
...(Object.keys(rooms).length ? { remoteQueueRooms: rooms } : {}),
|
|
452
|
+
...(Object.keys(members).length ? { remoteQueueMembers: members } : {}),
|
|
387
453
|
};
|
|
454
|
+
if (this.trackingQueuePressure()) {
|
|
455
|
+
// Metadata is advisory: omit capped or unknown entries rather than enlarge a delivery without bound.
|
|
456
|
+
const queuedAt = Object.create(null) as NonNullable<BusSnapshot["queuedAt"]>;
|
|
457
|
+
let bytes = 32;
|
|
458
|
+
for (const [peer, queue] of this.queues) {
|
|
459
|
+
const times = Object.create(null) as Record<string, number>;
|
|
460
|
+
const peerBytes = Buffer.byteLength(JSON.stringify(peer)) + 4;
|
|
461
|
+
for (const env of queue) {
|
|
462
|
+
const at = this.queueTimes.get(peer)?.get(env.id);
|
|
463
|
+
const entryBytes = Buffer.byteLength(JSON.stringify(env.id)) + 20;
|
|
464
|
+
if (typeof at !== "number" || bytes + peerBytes + entryBytes > QUEUE_TIME_SNAPSHOT_BYTES) continue;
|
|
465
|
+
times[env.id] = at; bytes += entryBytes;
|
|
466
|
+
}
|
|
467
|
+
if (Object.keys(times).length) { queuedAt[peer] = times; bytes += peerBytes; }
|
|
468
|
+
}
|
|
469
|
+
if (Object.keys(queuedAt).length) out.queuedAt = queuedAt;
|
|
470
|
+
}
|
|
388
471
|
if (includeJournal && this.journal) out.journal = this.journal.snapshot();
|
|
389
472
|
return out;
|
|
390
473
|
}
|
|
@@ -395,7 +478,7 @@ export class Bus {
|
|
|
395
478
|
if (_operationId && this.journal) {
|
|
396
479
|
const current = this.journal.snapshot();
|
|
397
480
|
if (current.revision === 0) {
|
|
398
|
-
this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId);
|
|
481
|
+
this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId, this.trackingQueuePressure());
|
|
399
482
|
}
|
|
400
483
|
const committedQueues = snapshot.queues;
|
|
401
484
|
snapshot = this.journal.snapshot().bus;
|
|
@@ -417,6 +500,24 @@ export class Bus {
|
|
|
417
500
|
for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
|
|
418
501
|
this.queues.clear();
|
|
419
502
|
for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
|
|
503
|
+
this.queueTimes.clear(); this.queueTimeEntries = 0;
|
|
504
|
+
if (this.trackingQueuePressure()) for (const [peer, queue] of this.queues) {
|
|
505
|
+
const provenance = snapshot.queuedAt;
|
|
506
|
+
const times = provenance && typeof provenance === "object" && !Array.isArray(provenance) && Object.hasOwn(provenance, peer) ? provenance[peer] : undefined;
|
|
507
|
+
for (const env of queue) {
|
|
508
|
+
const at = times && typeof times === "object" && !Array.isArray(times) && Object.hasOwn(times, env.id) ? times[env.id] : null;
|
|
509
|
+
this.recordQueueTime(peer, env.id, typeof at === "number" && Number.isSafeInteger(at) && at > 0 ? at : null);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.remoteQueueMembers.clear();
|
|
513
|
+
for (const [peer, member] of Object.entries(snapshot.remoteQueueMembers ?? {})) if (parseRemotePeerId(peer) && remoteFingerprint(member)) this.remoteQueueMembers.set(peer, member);
|
|
514
|
+
this.remoteQueueRooms.clear();
|
|
515
|
+
for (const [peer, room] of Object.entries(snapshot.remoteQueueRooms ?? {})) if (parseRemotePeerId(peer) && typeof room === "string" && room.length > 0 && room.length <= 256) this.remoteQueueRooms.set(peer, room);
|
|
516
|
+
this.remoteQueueExpires.clear();
|
|
517
|
+
for (const [peer, values] of Object.entries(snapshot.remoteQueueExpires ?? {})) {
|
|
518
|
+
if (!remoteId(peer)) continue;
|
|
519
|
+
this.remoteQueueExpires.set(peer, new Map(Object.entries(values).filter(([, expiry]) => Number.isSafeInteger(expiry) && expiry >= 0)));
|
|
520
|
+
}
|
|
420
521
|
this.prefaces.clear();
|
|
421
522
|
for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, body: uniqueMemoryLines(e.body), ...(e.to ? { to: [...e.to] } : {}) });
|
|
422
523
|
this.seen.clear();
|
|
@@ -460,6 +561,7 @@ export class Bus {
|
|
|
460
561
|
}
|
|
461
562
|
|
|
462
563
|
private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
|
|
564
|
+
if (remoteId(peer)) return out;
|
|
463
565
|
if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
|
|
464
566
|
return out.map((env, index) => {
|
|
465
567
|
if (index !== 0) return env;
|
|
@@ -474,6 +576,7 @@ export class Bus {
|
|
|
474
576
|
|
|
475
577
|
resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
|
|
476
578
|
if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
|
|
579
|
+
this.trackingQueuePressure();
|
|
477
580
|
const record = this.queueShow(id);
|
|
478
581
|
if (!record) throw new Error("unknown delivery");
|
|
479
582
|
const previous = this.journal.resolution(id);
|
|
@@ -481,6 +584,13 @@ export class Bus {
|
|
|
481
584
|
this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
|
|
482
585
|
return;
|
|
483
586
|
}
|
|
587
|
+
if (action === "retry" && remoteId(record.peer)) {
|
|
588
|
+
const peer = this.peers.get(record.peer), room = this.remoteQueueRooms.get(record.peer), member = this.remoteQueueMembers.get(record.peer);
|
|
589
|
+
if (record.state !== "needs_review" || record.originals.length !== 1 || !room || !remoteFingerprint(member) || peer?.remoteRoom !== room || peer.remoteMember !== member) throw new Error("remote original identity unavailable or changed; discard it or send a new message explicitly");
|
|
590
|
+
const deadline = this.remoteQueueExpires.get(record.peer)?.get(record.originals[0]!.id);
|
|
591
|
+
if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
|
|
592
|
+
this.checkRemotePublication(record.originals[0]!);
|
|
593
|
+
}
|
|
484
594
|
const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
|
|
485
595
|
// #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
|
|
486
596
|
// discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
|
|
@@ -489,7 +599,7 @@ export class Bus {
|
|
|
489
599
|
if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
|
|
490
600
|
if (record.revision !== revision) throw new Error("stale delivery revision");
|
|
491
601
|
if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
|
|
492
|
-
if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
|
|
602
|
+
if (action === "retry" && !remoteId(record.peer) && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
|
|
493
603
|
const queue = this.queues.get(record.peer) ?? [];
|
|
494
604
|
const hadQueue = this.queues.has(record.peer);
|
|
495
605
|
const before = [...queue];
|
|
@@ -549,8 +659,22 @@ export class Bus {
|
|
|
549
659
|
if (this.closed) return;
|
|
550
660
|
const owner = this.activeDeliveries.get(receipt.id);
|
|
551
661
|
if (!owner || owner !== peer || !this.journal) return;
|
|
662
|
+
this.trackingQueuePressure();
|
|
552
663
|
const id = receipt.id;
|
|
553
664
|
const before = this.journal.get(id);
|
|
665
|
+
if (this.peers.get(peer)?.remoteRoom !== undefined && receipt.state === "failed_safe") {
|
|
666
|
+
// A signed recipient NACK is a terminal known refusal, not a failed native
|
|
667
|
+
// turn eligible for poison retries. It records no recipient read evidence.
|
|
668
|
+
this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely");
|
|
669
|
+
this.activeDeliveries.delete(id); this.remoteTransport.delete(id);
|
|
670
|
+
for (const env of before?.originals ?? []) { this.attempts.delete(`${peer}:${env.id}`); this.remoteQueueExpires.get(peer)?.delete(env.id); }
|
|
671
|
+
const last = this.lastDelivery.get(peer);
|
|
672
|
+
if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
|
|
673
|
+
this.refreshRecoveryHold(peer);
|
|
674
|
+
this.onQueues?.();
|
|
675
|
+
void this.drain(peer);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
554
678
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
555
679
|
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
556
680
|
if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
|
|
@@ -578,6 +702,7 @@ export class Bus {
|
|
|
578
702
|
|
|
579
703
|
completeReply(peer: PeerId, envelopeId: string): void {
|
|
580
704
|
if (!this.journal) return;
|
|
705
|
+
this.trackingQueuePressure();
|
|
581
706
|
for (const [id, owner] of this.activeDeliveries) {
|
|
582
707
|
if (owner !== peer) continue;
|
|
583
708
|
const row = this.journal.get(id);
|
|
@@ -654,6 +779,7 @@ export class Bus {
|
|
|
654
779
|
|
|
655
780
|
/** 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. */
|
|
656
781
|
preface(id: PeerId, text: string): void {
|
|
782
|
+
if (remoteId(id)) return;
|
|
657
783
|
const body = uniqueMemoryLines(quoteNotes(text));
|
|
658
784
|
const existing = this.prefaces.get(id);
|
|
659
785
|
if (existing) {
|
|
@@ -669,6 +795,7 @@ export class Bus {
|
|
|
669
795
|
|
|
670
796
|
/** Another peer's shared note: rides in the preface like recall. The newest NOTES_KEPT stay; older ones remain in amem. */
|
|
671
797
|
note(id: PeerId, line: string): void {
|
|
798
|
+
if (remoteId(id)) return;
|
|
672
799
|
const existing = this.prefaces.get(id);
|
|
673
800
|
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
674
801
|
this.persist();
|
|
@@ -676,6 +803,7 @@ export class Bus {
|
|
|
676
803
|
|
|
677
804
|
/** A preface that went out with a failed delivery comes back ahead of whatever was prefaced meanwhile, instead of replacing it. */
|
|
678
805
|
private restorePreface(id: PeerId, env: Envelope): void {
|
|
806
|
+
if (remoteId(id)) return;
|
|
679
807
|
const now = this.prefaces.get(id);
|
|
680
808
|
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) });
|
|
681
809
|
}
|
|
@@ -687,6 +815,7 @@ export class Bus {
|
|
|
687
815
|
*/
|
|
688
816
|
discardDeferredHub(reason: string): string[] {
|
|
689
817
|
if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
|
|
818
|
+
this.trackingQueuePressure();
|
|
690
819
|
const pending = [...this.deferredHub];
|
|
691
820
|
const settled: string[] = [];
|
|
692
821
|
try {
|
|
@@ -713,6 +842,137 @@ export class Bus {
|
|
|
713
842
|
return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
|
|
714
843
|
}
|
|
715
844
|
|
|
845
|
+
/** Own accepted queue/preparation/active ids, without consulting the host callback. */
|
|
846
|
+
remotePendingIds(room: string): string[] {
|
|
847
|
+
const ids = new Set<string>();
|
|
848
|
+
for (const [peer, queue] of this.queues) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of queue) ids.add(env.id);
|
|
849
|
+
for (const [peer, batch] of this.preparing) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of batch) ids.add(env.id);
|
|
850
|
+
for (const { peer, originals } of this.remoteTransport.values()) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of originals) ids.add(env.id);
|
|
851
|
+
for (const [id, peer] of this.activeDeliveries) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of this.journal?.get(id)?.originals ?? []) ids.add(env.id);
|
|
852
|
+
return [...ids];
|
|
853
|
+
}
|
|
854
|
+
/** Remote preflight precedes limiter admission; publication rechecks before effects. */
|
|
855
|
+
checkRemotePublication(env: Envelope): string[] {
|
|
856
|
+
if (env.to?.some(remoteId) && this.opts.remotePrivate) {
|
|
857
|
+
try { if (this.opts.remotePrivate(env) !== false) env.private = true; }
|
|
858
|
+
catch { env.private = true; }
|
|
859
|
+
}
|
|
860
|
+
const rooms = new Set<string>(), targets = [...new Set((env.to ?? []).filter(remoteId))];
|
|
861
|
+
if (remoteId(env.from) && !parseRemotePeerId(env.from)) throw new PreEffectToolRefusal("invalid remote sender");
|
|
862
|
+
for (const target of targets) {
|
|
863
|
+
const peer = this.peers.get(target);
|
|
864
|
+
if (!parseRemotePeerId(target) || !peer?.remoteRoom || !remoteFingerprint(peer.remoteMember)) throw new PreEffectToolRefusal("remote recipient unavailable");
|
|
865
|
+
const bound = this.remoteQueueRooms.get(target), member = this.remoteQueueMembers.get(target);
|
|
866
|
+
if (this.hasRemoteWork(target) && (!bound || !member || bound !== peer.remoteRoom || member !== peer.remoteMember)) throw new PreEffectToolRefusal("remote queued identity binding changed");
|
|
867
|
+
const ttl = peer.remoteQueueTtlMs ?? REMOTE_QUEUE_TTL_MS;
|
|
868
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || !Number.isSafeInteger(Date.now() + ttl)) throw new PreEffectToolRefusal("remote queue TTL unavailable");
|
|
869
|
+
rooms.add(peer.remoteRoom);
|
|
870
|
+
}
|
|
871
|
+
// ponytail: one original has one stable wire identity. A follow-up may derive
|
|
872
|
+
// stable per-target wire ids and account each delivery separately before fan-out.
|
|
873
|
+
if (targets.length > 1) throw new PreEffectToolRefusal(`remote targets ${targets.slice(0, 64).join(", ")}${targets.length > 64 ? ` and ${targets.length - 64} more` : ""}: send to each remote peer separately`);
|
|
874
|
+
for (const room of rooms) {
|
|
875
|
+
const pending = new Set([...this.remotePendingIds(room), ...(this.remoteReservations.get(room) ?? [])]);
|
|
876
|
+
let external: readonly string[];
|
|
877
|
+
try { external = this.opts.remotePending?.(room) ?? []; } catch { throw new PreEffectToolRefusal("remote queue unavailable"); }
|
|
878
|
+
if (!Array.isArray(external) || external.length > REMOTE_ROOM_CAP || external.some(id => typeof id !== "string" || id.length < 1 || id.length > 256)) throw new PreEffectToolRefusal("remote queue unavailable");
|
|
879
|
+
for (const id of external) pending.add(id);
|
|
880
|
+
pending.add(env.id);
|
|
881
|
+
if (pending.size > REMOTE_ROOM_CAP) throw new PreEffectToolRefusal("remote room queue is full");
|
|
882
|
+
}
|
|
883
|
+
return [...rooms];
|
|
884
|
+
}
|
|
885
|
+
private hasRemoteWork(peer: string, pending?: ReadonlySet<string>): boolean {
|
|
886
|
+
return (this.queues.get(peer)?.length ?? 0) > 0 || this.preparing.has(peer) || [...this.activeDeliveries.values()].includes(peer) || [...this.remoteTransport.values()].some(value => value.peer === peer) || (pending ?? new Set(this.journal?.pendingPeers() ?? [])).has(peer);
|
|
887
|
+
}
|
|
888
|
+
private remoteBindingSnapshot(values: ReadonlyMap<string, string>): Record<string, string> {
|
|
889
|
+
if (!values.size) return {};
|
|
890
|
+
const pending = new Set(this.journal?.pendingPeers() ?? []);
|
|
891
|
+
return Object.fromEntries([...values].filter(([peer]) => this.hasRemoteWork(peer, pending)));
|
|
892
|
+
}
|
|
893
|
+
private remoteMemberSnapshot(): Record<string, string> { return this.remoteBindingSnapshot(this.remoteQueueMembers); }
|
|
894
|
+
private remoteRoomSnapshot(): Record<string, string> { return this.remoteBindingSnapshot(this.remoteQueueRooms); }
|
|
895
|
+
private remoteExpirySnapshot(): Record<string, Record<string, number>> {
|
|
896
|
+
const snapshot: Record<string, Record<string, number>> = {};
|
|
897
|
+
for (const [peer, expiries] of this.remoteQueueExpires) {
|
|
898
|
+
const pending = new Set([...(this.queues.get(peer) ?? []), ...(this.preparing.get(peer) ?? [])].map(env => env.id));
|
|
899
|
+
for (const { peer: target, originals } of this.remoteTransport.values()) if (target === peer) for (const env of originals) pending.add(env.id);
|
|
900
|
+
for (const [id, target] of this.activeDeliveries) if (target === peer) for (const env of this.journal?.get(id)?.originals ?? []) pending.add(env.id);
|
|
901
|
+
// Uncertainty is still pending work. Keep its known TTL through checkpoints,
|
|
902
|
+
// using unresolved rows only, never completed delivery history.
|
|
903
|
+
for (const row of this.journal?.unresolved(peer) ?? []) for (const env of row.originals) pending.add(env.id);
|
|
904
|
+
for (const id of expiries.keys()) if (!pending.has(id)) expiries.delete(id);
|
|
905
|
+
if (expiries.size) snapshot[peer] = Object.fromEntries(expiries);
|
|
906
|
+
else this.remoteQueueExpires.delete(peer);
|
|
907
|
+
}
|
|
908
|
+
return snapshot;
|
|
909
|
+
}
|
|
910
|
+
/** Metadata-only timer hint; an unknown retained deadline/binding is due immediately. */
|
|
911
|
+
nextRemoteExpiry(): number | undefined {
|
|
912
|
+
let next: number | undefined;
|
|
913
|
+
for (const [peer, queue] of this.queues) {
|
|
914
|
+
if (!remoteId(peer) || !queue.length) continue;
|
|
915
|
+
if (!this.remoteQueueRooms.has(peer) || !this.remoteQueueMembers.has(peer)) return Date.now();
|
|
916
|
+
const expiries = this.remoteQueueExpires.get(peer);
|
|
917
|
+
for (const env of queue) {
|
|
918
|
+
const expiry = expiries?.get(env.id);
|
|
919
|
+
if (expiry === undefined) return Date.now();
|
|
920
|
+
next = next === undefined ? expiry : Math.min(next, expiry);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return next;
|
|
924
|
+
}
|
|
925
|
+
/** Expiry is a durable uncertain outcome, never a silent retry/drop or a fresh timestamp. */
|
|
926
|
+
expireRemoteQueues(now = Date.now()): number {
|
|
927
|
+
if (!Number.isSafeInteger(now) || now < 0) throw new Error("invalid remote queue clock");
|
|
928
|
+
if (!this.journal || this.closed) return 0; // Without a journal there is no durable sink for uncertain work.
|
|
929
|
+
const expired = [...this.queues].filter(([peer]) => remoteId(peer)).flatMap(([peer, queue]) => queue.filter(env => {
|
|
930
|
+
const expiry = this.remoteQueueExpires.get(peer)?.get(env.id);
|
|
931
|
+
return this.remoteQueueRooms.get(peer) === undefined || this.remoteQueueMembers.get(peer) === undefined || expiry === undefined || expiry <= now;
|
|
932
|
+
}).map(env => ({ peer, env })));
|
|
933
|
+
if (!expired.length) return 0;
|
|
934
|
+
const queues = new Map([...this.queues].map(([peer, queue]) => [peer, [...queue]]));
|
|
935
|
+
const expiries = new Map([...this.remoteQueueExpires].map(([peer, values]) => [peer, new Map(values)]));
|
|
936
|
+
const held = [...this.recoveryHeldPeers], outcomes = this.pendingOutcomes.length;
|
|
937
|
+
try {
|
|
938
|
+
for (const { peer, env } of expired) {
|
|
939
|
+
const queue = this.queues.get(peer)!;
|
|
940
|
+
const index = queue.findIndex(item => item.id === env.id);
|
|
941
|
+
if (index >= 0) queue.splice(index, 1);
|
|
942
|
+
const known = this.remoteQueueExpires.get(peer)?.has(env.id);
|
|
943
|
+
this.remoteQueueExpires.get(peer)?.delete(env.id);
|
|
944
|
+
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" });
|
|
945
|
+
this.recoveryHeldPeers.add(peer);
|
|
946
|
+
}
|
|
947
|
+
this.persist();
|
|
948
|
+
} catch (error) {
|
|
949
|
+
for (const [peer, before] of queues) this.queues.get(peer)!.splice(0, this.queues.get(peer)!.length, ...before);
|
|
950
|
+
this.remoteQueueExpires.clear(); for (const [peer, values] of expiries) this.remoteQueueExpires.set(peer, values);
|
|
951
|
+
this.recoveryHeldPeers.clear(); for (const peer of held) this.recoveryHeldPeers.add(peer);
|
|
952
|
+
this.pendingOutcomes.length = outcomes;
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
this.onQueues?.();
|
|
956
|
+
return expired.length;
|
|
957
|
+
}
|
|
958
|
+
/** A room may acknowledge local admission only with a live, known-available journal. */
|
|
959
|
+
deliveryJournalAvailable(): boolean { return this.journal !== undefined && !this.storageError && !this.closed; }
|
|
960
|
+
/** Explicit person disposition metadata only, never inferred from transport completion. */
|
|
961
|
+
remoteDisposition(deliveryId: string): "completed" | "retry" | "discard" | undefined {
|
|
962
|
+
const resolution = this.journal?.resolution(deliveryId);
|
|
963
|
+
return resolution?.actor === "user" ? resolution.action : undefined;
|
|
964
|
+
}
|
|
965
|
+
/** A late signed queue receipt is evidence for the person; it never auto-settles uncertainty. */
|
|
966
|
+
noteLateRemoteReceipt(peer: string, deliveryId: string, outcome: "completed" | "failed_safe"): boolean {
|
|
967
|
+
if ((outcome !== "completed" && outcome !== "failed_safe") || !parseRemotePeerId(peer) || !this.peers.get(peer)?.remoteRoom || !this.journal || this.storageError || this.closed) return false;
|
|
968
|
+
const row = this.journal.get(deliveryId), adapter = this.peers.get(peer)!;
|
|
969
|
+
if (!row || row.peer !== peer || row.state !== "needs_review" || this.remoteQueueRooms.get(peer) !== adapter.remoteRoom || this.remoteQueueMembers.get(peer) !== adapter.remoteMember) return false;
|
|
970
|
+
const reason = outcome === "completed" ? "late signed completed receipt: remote queue admitted; remains needs_review" : "late signed failed_safe receipt; remains needs_review";
|
|
971
|
+
if (row.reason !== reason) this.journal.transition(row.id, "needs_review", reason, row.revision);
|
|
972
|
+
this.onQueues?.();
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
|
|
716
976
|
/** Returns the peers the envelope was accepted for, including durable deferred notices. */
|
|
717
977
|
publish(env: Envelope): PeerId[] {
|
|
718
978
|
return this.publishResult(env).targets;
|
|
@@ -722,6 +982,7 @@ export class Bus {
|
|
|
722
982
|
publishResult(env: Envelope): PublishResult {
|
|
723
983
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
724
984
|
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
|
|
985
|
+
this.checkRemotePublication(env);
|
|
725
986
|
if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
|
|
726
987
|
if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
|
|
727
988
|
const targets = this.audience(env);
|
|
@@ -737,48 +998,52 @@ export class Bus {
|
|
|
737
998
|
private publishNow(env: Envelope): PublishResult {
|
|
738
999
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
739
1000
|
if (this.seen.has(env.id)) return { targets: [] };
|
|
740
|
-
this.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
757
|
-
this.steering++;
|
|
758
|
-
const deliveryId = crypto.randomUUID();
|
|
759
|
-
this.activeDeliveries.set(deliveryId, id);
|
|
760
|
-
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
761
|
-
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
762
|
-
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
763
|
-
this.deliveryFailed(id);
|
|
764
|
-
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
765
|
-
this.activeDeliveries.delete(deliveryId);
|
|
766
|
-
if (!this.journal) this.enqueue(id, env, true);
|
|
767
|
-
}).finally(() => { this.steering--; });
|
|
768
|
-
} else this.enqueue(id, env);
|
|
769
|
-
} } finally { this.suppressDrain = false; }
|
|
770
|
-
if (this.journal) {
|
|
771
|
-
this.persist();
|
|
772
|
-
if (!this.flushingHub) this.onQueues?.();
|
|
773
|
-
for (const id of targets) {
|
|
1001
|
+
const rooms = this.checkRemotePublication(env);
|
|
1002
|
+
for (const room of rooms) { const reserved = this.remoteReservations.get(room) ?? new Set<string>(); reserved.add(env.id); this.remoteReservations.set(room, reserved); }
|
|
1003
|
+
try {
|
|
1004
|
+
this.seen.set(env.id, env);
|
|
1005
|
+
if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
1006
|
+
|
|
1007
|
+
// fyi is for the record (console now, task board from M4), never worth an agent's turn.
|
|
1008
|
+
const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
|
|
1009
|
+
this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
|
|
1010
|
+
if (dropped) { this.persist(); return { targets: [], dropped }; }
|
|
1011
|
+
|
|
1012
|
+
const hushed = this.hushed(env);
|
|
1013
|
+
if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
|
|
1014
|
+
const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
|
|
1015
|
+
this.suppressDrain = !!this.journal;
|
|
1016
|
+
try { for (const id of targets) {
|
|
774
1017
|
const peer = this.peers.get(id);
|
|
775
|
-
if (!
|
|
776
|
-
|
|
1018
|
+
if (!peer) { this.enqueue(id, env); continue; }
|
|
1019
|
+
if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy") {
|
|
1020
|
+
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
1021
|
+
this.steering++;
|
|
1022
|
+
const deliveryId = crypto.randomUUID();
|
|
1023
|
+
this.activeDeliveries.set(deliveryId, id);
|
|
1024
|
+
if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
|
|
1025
|
+
const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
|
|
1026
|
+
steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
|
|
1027
|
+
this.deliveryFailed(id);
|
|
1028
|
+
if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
|
|
1029
|
+
this.activeDeliveries.delete(deliveryId);
|
|
1030
|
+
if (!this.journal) this.enqueue(id, env, true);
|
|
1031
|
+
}).finally(() => { this.steering--; });
|
|
1032
|
+
} else this.enqueue(id, env);
|
|
1033
|
+
} } finally { this.suppressDrain = false; }
|
|
1034
|
+
if (this.journal) {
|
|
1035
|
+
this.persist();
|
|
1036
|
+
if (!this.flushingHub) this.onQueues?.();
|
|
1037
|
+
for (const id of targets) {
|
|
1038
|
+
const peer = this.peers.get(id);
|
|
1039
|
+
if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
1040
|
+
else void this.drain(id);
|
|
1041
|
+
}
|
|
777
1042
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1043
|
+
const offRoster = targets.filter(id => !this.isRostered(id));
|
|
1044
|
+
if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
|
|
1045
|
+
return { targets };
|
|
1046
|
+
} finally { for (const room of rooms) { const reserved = this.remoteReservations.get(room); reserved?.delete(env.id); if (!reserved?.size) this.remoteReservations.delete(room); } }
|
|
782
1047
|
}
|
|
783
1048
|
|
|
784
1049
|
isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
|
|
@@ -798,7 +1063,7 @@ export class Bus {
|
|
|
798
1063
|
/** Who an envelope is queued for: its `to`, or every peer but its sender. */
|
|
799
1064
|
audience(env: Envelope): PeerId[] {
|
|
800
1065
|
const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
|
|
801
|
-
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)));
|
|
1066
|
+
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)) && (env.to !== undefined || !remoteId(id)));
|
|
802
1067
|
}
|
|
803
1068
|
|
|
804
1069
|
/** The recipients of `env` that the silence policy holds it back from, each with why (issue #107). */
|
|
@@ -875,7 +1140,7 @@ export class Bus {
|
|
|
875
1140
|
}
|
|
876
1141
|
|
|
877
1142
|
private memoryDelivery(peer: PeerId, id: string, out: Envelope[], field: "rendered" | "delivered", originals: Envelope[] = out): void {
|
|
878
|
-
if ([...out, ...originals].some(env => env.private)) return;
|
|
1143
|
+
if (remoteId(peer) || [...out, ...originals].some(env => env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
|
|
879
1144
|
try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
|
|
880
1145
|
}
|
|
881
1146
|
|
|
@@ -896,6 +1161,54 @@ export class Bus {
|
|
|
896
1161
|
return this.queues.get(id)?.length ?? 0;
|
|
897
1162
|
}
|
|
898
1163
|
|
|
1164
|
+
/** New automatic offers use only bounded pending metadata, never queue/history bodies. */
|
|
1165
|
+
assignmentQueue(peer: PeerId): QueuePressure {
|
|
1166
|
+
const queued = this.queued(peer), tracking = this.trackingQueuePressure();
|
|
1167
|
+
if (this.storageError) return { queued };
|
|
1168
|
+
if (queued === 0) return { queued: 0, oldestQueuedAt: null };
|
|
1169
|
+
if (!tracking) return { queued };
|
|
1170
|
+
// A durable summary cannot revive provenance that this host discarded while tracking was off.
|
|
1171
|
+
const times = this.queueTimes.get(peer);
|
|
1172
|
+
let oldest: number | undefined;
|
|
1173
|
+
for (const env of this.queues.get(peer) ?? []) {
|
|
1174
|
+
const at = times?.get(env.id);
|
|
1175
|
+
if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return { queued };
|
|
1176
|
+
oldest = oldest === undefined ? at : Math.min(oldest, at);
|
|
1177
|
+
}
|
|
1178
|
+
if (this.journal) {
|
|
1179
|
+
try {
|
|
1180
|
+
const saved = this.journal.pendingQueueMetadata(peer);
|
|
1181
|
+
return saved?.queued === queued ? saved : { queued };
|
|
1182
|
+
} catch { return { queued }; }
|
|
1183
|
+
}
|
|
1184
|
+
return oldest === undefined ? { queued } : { queued, oldestQueuedAt: oldest };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private trackingQueuePressure(): boolean {
|
|
1188
|
+
let tracking = false;
|
|
1189
|
+
try { tracking = this.opts.trackQueuePressure?.() === true; } catch { /* unknown policy is not enabled */ }
|
|
1190
|
+
if (!tracking && this.queuePressureWasTracked) { this.queueTimes.clear(); this.queueTimeEntries = 0; }
|
|
1191
|
+
this.queuePressureWasTracked = tracking;
|
|
1192
|
+
this.journal?.setQueueMetadataTracking(tracking);
|
|
1193
|
+
return tracking;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private recordQueueTime(peer: PeerId, id: string, at: number | null): void {
|
|
1197
|
+
if (!this.trackingQueuePressure()) return;
|
|
1198
|
+
let times = this.queueTimes.get(peer);
|
|
1199
|
+
if (!times) {
|
|
1200
|
+
if (this.queueTimes.size >= QUEUE_TIME_PEERS_CAP) this.dropQueueTimes(this.queueTimes.keys().next().value!);
|
|
1201
|
+
this.queueTimes.set(peer, times = new Map());
|
|
1202
|
+
}
|
|
1203
|
+
if (!times.has(id)) { times.set(id, at); this.queueTimeEntries++; }
|
|
1204
|
+
while (times.size > QUEUE_TIME_CAP) { times.delete(times.keys().next().value!); this.queueTimeEntries--; }
|
|
1205
|
+
while (this.queueTimeEntries > QUEUE_TIME_TOTAL_CAP) this.dropQueueTimes(this.queueTimes.keys().next().value!);
|
|
1206
|
+
}
|
|
1207
|
+
private dropQueueTimes(peer: PeerId): void {
|
|
1208
|
+
this.queueTimeEntries -= this.queueTimes.get(peer)?.size ?? 0;
|
|
1209
|
+
this.queueTimes.delete(peer);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
899
1212
|
/**
|
|
900
1213
|
* Queued envelopes that skip the batch window (issue #41: `queued 3` hid which ones the peer still waits out).
|
|
901
1214
|
* An `important` envelope that is in a queue is one that could not be steered, so it does not interrupt a
|
|
@@ -930,8 +1243,14 @@ export class Bus {
|
|
|
930
1243
|
const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
|
|
931
1244
|
const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
|
|
932
1245
|
let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
|
|
1246
|
+
const clock = this.trackingQueuePressure() ? Date.now() : 0;
|
|
1247
|
+
let enteredAt = this.queueTimes.get(id)?.has(env.id) ? this.queueTimes.get(id)!.get(env.id)!
|
|
1248
|
+
: front || this.flushingHub || !Number.isSafeInteger(clock) || clock <= 0 ? null : clock;
|
|
1249
|
+
if (this.trackingQueuePressure() && !this.queueTimes.get(id)?.has(env.id) && queue.some(old => old.id === env.id)) enteredAt = null;
|
|
1250
|
+
if (preparing) enteredAt = this.queueTimes.get(id)?.get(preparing.id) ?? null;
|
|
933
1251
|
if (replace >= 0) {
|
|
934
1252
|
const old = queue[replace]!;
|
|
1253
|
+
enteredAt = this.queueTimes.get(id)?.get(old.id) ?? null;
|
|
935
1254
|
// Keep the first milestone's deadline, so continuous updates cannot create another batching window.
|
|
936
1255
|
queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
|
|
937
1256
|
queue[replace] = queuedEnv;
|
|
@@ -939,16 +1258,26 @@ export class Bus {
|
|
|
939
1258
|
this.discardQueued(id, old, "superseded supervision milestone");
|
|
940
1259
|
} else if (front) queue.unshift(queuedEnv);
|
|
941
1260
|
else queue.push(queuedEnv);
|
|
1261
|
+
this.recordQueueTime(id, queuedEnv.id, enteredAt);
|
|
942
1262
|
if (preparing && !this.withdrawn.has(preparing.id)) {
|
|
943
1263
|
this.markWithdrawn(preparing.id);
|
|
944
1264
|
this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
|
|
945
1265
|
}
|
|
946
|
-
if (queue.length > this.opts.queueCap) {
|
|
1266
|
+
if (!remoteId(id) && queue.length > this.opts.queueCap) {
|
|
947
1267
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
948
1268
|
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
949
1269
|
if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
|
|
950
1270
|
this.emit({ t: "overflow", env: lost!, peer: id });
|
|
951
1271
|
}
|
|
1272
|
+
if (remoteId(id) && !front) {
|
|
1273
|
+
const room = this.peers.get(id)?.remoteRoom;
|
|
1274
|
+
if (room !== undefined) this.remoteQueueRooms.set(id, room);
|
|
1275
|
+
const member = this.peers.get(id)?.remoteMember;
|
|
1276
|
+
if (member !== undefined) this.remoteQueueMembers.set(id, member);
|
|
1277
|
+
const expiries = this.remoteQueueExpires.get(id) ?? new Map<string, number>();
|
|
1278
|
+
if (!expiries.has(env.id)) expiries.set(env.id, Date.now() + (this.peers.get(id)?.remoteQueueTtlMs ?? REMOTE_QUEUE_TTL_MS));
|
|
1279
|
+
this.remoteQueueExpires.set(id, expiries);
|
|
1280
|
+
}
|
|
952
1281
|
if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
|
|
953
1282
|
// Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
|
|
954
1283
|
const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
|
|
@@ -1009,7 +1338,9 @@ export class Bus {
|
|
|
1009
1338
|
|
|
1010
1339
|
/** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
|
|
1011
1340
|
private deliverable(id: PeerId): boolean {
|
|
1012
|
-
|
|
1341
|
+
const peer = this.peers.get(id);
|
|
1342
|
+
const remoteBinding = !remoteId(id) || !!peer?.remoteRoom && this.remoteQueueRooms.get(id) === peer.remoteRoom && this.remoteQueueMembers.get(id) === peer.remoteMember;
|
|
1343
|
+
return remoteBinding && !this.held(id) && this.stateOf(id) === "idle" && !peer?.pullOnly;
|
|
1013
1344
|
}
|
|
1014
1345
|
|
|
1015
1346
|
/**
|
|
@@ -1024,6 +1355,7 @@ export class Bus {
|
|
|
1024
1355
|
* steps if loss is ever observed.
|
|
1025
1356
|
*/
|
|
1026
1357
|
pull(id: PeerId): Envelope[] | undefined {
|
|
1358
|
+
if (remoteId(id)) return undefined; // A local inbox read is not signed remote queue admission.
|
|
1027
1359
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
1028
1360
|
if (this.held(id)) return undefined;
|
|
1029
1361
|
const queue = this.queues.get(id) ?? [];
|
|
@@ -1061,22 +1393,23 @@ export class Bus {
|
|
|
1061
1393
|
|
|
1062
1394
|
private async drain(id: PeerId): Promise<void> {
|
|
1063
1395
|
if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
|
|
1396
|
+
if (remoteId(id)) this.expireRemoteQueues();
|
|
1064
1397
|
this.draining.add(id);
|
|
1065
1398
|
try {
|
|
1066
1399
|
const peer = this.peers.get(id)!;
|
|
1067
1400
|
const queue = this.queues.get(id)!;
|
|
1068
1401
|
while (!this.storageError && queue.length && this.deliverable(id)) {
|
|
1069
1402
|
if (this.dropIrrelevant(id, queue) && !queue.length) break;
|
|
1070
|
-
const delay = this.wait(queue);
|
|
1403
|
+
const delay = remoteId(id) ? 0 : this.wait(queue);
|
|
1071
1404
|
if (delay > 0) { this.arm(id, delay); break; }
|
|
1072
|
-
const preface = this.prefaces.get(id);
|
|
1405
|
+
const preface = remoteId(id) ? undefined : this.prefaces.get(id);
|
|
1073
1406
|
// Durable queues retain the batch while asynchronous condensation runs. Another publish
|
|
1074
1407
|
// may checkpoint during that wait; it must not checkpoint the batch out of existence.
|
|
1075
1408
|
if (!this.journal) this.prefaces.delete(id);
|
|
1076
1409
|
const batch = this.take(id, queue, !this.journal);
|
|
1077
1410
|
this.preparing.set(id, batch);
|
|
1078
1411
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
1079
|
-
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
1412
|
+
const mayCondense = this.opts.condense && !remoteId(id) && !delivery.some((e) => e.priority === "important" || remoteId(e.from));
|
|
1080
1413
|
this.condensing += mayCondense ? 1 : 0;
|
|
1081
1414
|
let out: Envelope[];
|
|
1082
1415
|
try {
|
|
@@ -1096,7 +1429,7 @@ export class Bus {
|
|
|
1096
1429
|
}
|
|
1097
1430
|
const deliveryId = crypto.randomUUID();
|
|
1098
1431
|
if (this.journal) {
|
|
1099
|
-
if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
|
|
1432
|
+
if ((!remoteId(id) && this.prefaces.get(id) !== preface) || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
|
|
1100
1433
|
const queued = [...queue], seen = [...this.seen];
|
|
1101
1434
|
for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
|
|
1102
1435
|
this.prefaces.delete(id);
|
|
@@ -1112,8 +1445,14 @@ export class Bus {
|
|
|
1112
1445
|
this.activeDeliveries.set(deliveryId, id);
|
|
1113
1446
|
} else this.remember(out);
|
|
1114
1447
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
1448
|
+
if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
|
|
1115
1449
|
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
1116
1450
|
try {
|
|
1451
|
+
if (remoteId(id)) {
|
|
1452
|
+
const env = delivery[0], expiry = env && this.remoteQueueExpires.get(id)?.get(env.id);
|
|
1453
|
+
if (!env || delivery.length !== 1 || expiry === undefined || expiry <= Date.now() || !peer.setRemoteDeadline) throw new Error("remote queue deadline unavailable");
|
|
1454
|
+
peer.setRemoteDeadline(env.id, expiry);
|
|
1455
|
+
}
|
|
1117
1456
|
try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
|
|
1118
1457
|
if (this.journal) await peer.deliver(out, deliveryId);
|
|
1119
1458
|
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
@@ -1121,7 +1460,7 @@ export class Bus {
|
|
|
1121
1460
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
1122
1461
|
else this.failed(id, delivery);
|
|
1123
1462
|
break;
|
|
1124
|
-
}
|
|
1463
|
+
} finally { this.remoteTransport.delete(deliveryId); }
|
|
1125
1464
|
}
|
|
1126
1465
|
} catch { this.storageError = "delivery journal unavailable"; }
|
|
1127
1466
|
finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
|
|
@@ -1172,7 +1511,7 @@ export class Bus {
|
|
|
1172
1511
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1173
1512
|
if (failedBefore && batch.length) break;
|
|
1174
1513
|
batch.push(env);
|
|
1175
|
-
if (failedBefore || batch.length === DIGEST_MAX) break;
|
|
1514
|
+
if (failedBefore || remoteId(id) || batch.length === DIGEST_MAX) break;
|
|
1176
1515
|
}
|
|
1177
1516
|
if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1178
1517
|
return batch;
|
|
@@ -1245,6 +1584,10 @@ export class Bus {
|
|
|
1245
1584
|
|
|
1246
1585
|
private emit(e: BusEvent): void {
|
|
1247
1586
|
if (this.flushingHub) { this.deferredEvents.push(e); return; }
|
|
1587
|
+
if ("env" in e && (remoteId(e.env.from) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
|
|
1588
|
+
const { refs: _, ...env } = e.env;
|
|
1589
|
+
e = { ...e, env: { ...env, body: "[remote room message withheld]", ...(env.to ? { to: env.to.filter(id => /^[a-z][a-z0-9-]{0,31}$/.test(id) || !!parseRemotePeerId(id)) } : {}) }, ...("reason" in e ? { reason: "remote room delivery" } : {}) };
|
|
1590
|
+
}
|
|
1248
1591
|
for (const tap of this.taps) tap(e);
|
|
1249
1592
|
}
|
|
1250
1593
|
}
|