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