@staix/agent-hub 0.6.3 → 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 +14 -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 +32 -11
- 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/free-text.ts +8 -0
- package/src/cli/main.ts +52 -2
- package/src/cli/status-lines.ts +6 -1
- package/src/cli/terminal-recovery.ts +41 -1
- package/src/cli/upgrade-runtime.ts +8 -8
- package/src/hub/bus.ts +307 -37
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +109 -20
- package/src/hub/delivery-journal.ts +830 -0
- package/src/hub/envelope.ts +8 -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,17 +86,41 @@ 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>();
|
|
94
|
+
/**
|
|
95
|
+
* The queue lengths changed. `publish` emits its envelope event *before* it enqueues, and a delivery emits
|
|
96
|
+
* nothing at all, so anything that renders `queued` off a bus event records the count from before the change
|
|
97
|
+
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
98
|
+
*/
|
|
99
|
+
onQueues?: () => void;
|
|
77
100
|
private recoveryHeld = false;
|
|
78
101
|
private steering = 0;
|
|
79
102
|
private condensing = 0;
|
|
103
|
+
private closed = false;
|
|
104
|
+
storageError?: string;
|
|
80
105
|
|
|
81
106
|
constructor(opts: Partial<BusOptions> = {}) {
|
|
82
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
|
+
}
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
add(peer: PeerAdapter): void {
|
|
86
121
|
this.peers.set(peer.id, peer);
|
|
87
122
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
123
|
+
peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
|
|
88
124
|
peer.onMessage = (text, opts) => {
|
|
89
125
|
const { priority, body } = parseMarker(text);
|
|
90
126
|
if (!body) return;
|
|
@@ -103,14 +139,51 @@ export class Bus {
|
|
|
103
139
|
// The adapter got the condensed list; what has to come back is what that list replaced.
|
|
104
140
|
const last = this.lastDelivery.get(peer.id);
|
|
105
141
|
const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
|
|
106
|
-
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);
|
|
107
152
|
};
|
|
108
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
|
+
}
|
|
109
159
|
this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
|
|
110
160
|
void this.drain(peer.id);
|
|
111
161
|
};
|
|
112
162
|
}
|
|
113
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
|
+
|
|
114
187
|
/** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
|
|
115
188
|
setRecoveryHold(held: boolean): void {
|
|
116
189
|
this.recoveryHeld = held;
|
|
@@ -128,20 +201,35 @@ export class Bus {
|
|
|
128
201
|
}
|
|
129
202
|
|
|
130
203
|
/** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
|
|
131
|
-
snapshot(): BusSnapshot {
|
|
132
|
-
|
|
204
|
+
snapshot(includeJournal = true): BusSnapshot {
|
|
205
|
+
const out: BusSnapshot = {
|
|
133
206
|
schemaVersion: 1,
|
|
134
207
|
queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
|
|
135
208
|
prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
|
|
136
209
|
seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
|
|
137
210
|
attempts: Object.fromEntries(this.attempts),
|
|
138
211
|
withdrawn: [...this.withdrawn],
|
|
212
|
+
manualPaused: [...this.manualPaused],
|
|
139
213
|
};
|
|
214
|
+
if (includeJournal && this.journal) out.journal = this.journal.snapshot();
|
|
215
|
+
return out;
|
|
140
216
|
}
|
|
141
217
|
|
|
142
218
|
/** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
|
|
143
|
-
restore(snapshot: BusSnapshot): void {
|
|
219
|
+
restore(snapshot: BusSnapshot, _operationId?: string): void {
|
|
144
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 {
|
|
145
233
|
this.queues.clear();
|
|
146
234
|
for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
|
|
147
235
|
this.prefaces.clear();
|
|
@@ -152,6 +240,122 @@ export class Bus {
|
|
|
152
240
|
for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
|
|
153
241
|
this.withdrawn.clear();
|
|
154
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
|
+
}
|
|
155
359
|
}
|
|
156
360
|
|
|
157
361
|
queueIds(id: PeerId): string[] {
|
|
@@ -175,11 +379,13 @@ export class Bus {
|
|
|
175
379
|
|
|
176
380
|
pause(id: PeerId): void {
|
|
177
381
|
this.paused.add(id);
|
|
382
|
+
this.persist();
|
|
178
383
|
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
179
384
|
}
|
|
180
385
|
|
|
181
386
|
resume(id: PeerId): void {
|
|
182
387
|
if (!this.paused.delete(id)) return;
|
|
388
|
+
this.persist();
|
|
183
389
|
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
184
390
|
void this.drain(id);
|
|
185
391
|
}
|
|
@@ -191,13 +397,16 @@ export class Bus {
|
|
|
191
397
|
// Recovery and session recall may both contribute context. Keep the original id/hop so a queued
|
|
192
398
|
// preface remains deduplicated and append the new, separately generated context.
|
|
193
399
|
this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
|
|
400
|
+
this.persist();
|
|
194
401
|
return;
|
|
195
402
|
}
|
|
196
403
|
this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
|
|
404
|
+
this.persist();
|
|
197
405
|
}
|
|
198
406
|
|
|
199
407
|
/** Returns the peers the envelope was queued for. */
|
|
200
408
|
publish(env: Envelope): PeerId[] {
|
|
409
|
+
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
201
410
|
if (this.seen.has(env.id)) return [];
|
|
202
411
|
this.seen.set(env.id, env);
|
|
203
412
|
if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
@@ -205,16 +414,36 @@ export class Bus {
|
|
|
205
414
|
// fyi is for the record (console now, task board from M4), never worth an agent's turn.
|
|
206
415
|
const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
|
|
207
416
|
this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
|
|
208
|
-
if (dropped) return [];
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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") {
|
|
214
426
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
215
427
|
this.steering++;
|
|
216
|
-
|
|
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--; });
|
|
217
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
|
+
}
|
|
218
447
|
}
|
|
219
448
|
return targets;
|
|
220
449
|
}
|
|
@@ -237,6 +466,8 @@ export class Bus {
|
|
|
237
466
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
238
467
|
if (i !== -1) removed = queue.splice(i, 1).length > 0;
|
|
239
468
|
}
|
|
469
|
+
if (removed) this.onQueues?.();
|
|
470
|
+
this.persist();
|
|
240
471
|
return removed;
|
|
241
472
|
}
|
|
242
473
|
|
|
@@ -244,17 +475,30 @@ export class Bus {
|
|
|
244
475
|
return this.queues.get(id)?.length ?? 0;
|
|
245
476
|
}
|
|
246
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Queued envelopes that skip the batch window (issue #41: `queued 3` hid which ones the peer still waits out).
|
|
480
|
+
* An `important` envelope that is in a queue is one that could not be steered, so it does not interrupt a
|
|
481
|
+
* running turn; it is why the queue is delivered the moment the peer goes idle.
|
|
482
|
+
*/
|
|
483
|
+
queuedImportant(id: PeerId): number {
|
|
484
|
+
return (this.queues.get(id) ?? []).filter((e) => e.priority === "important").length;
|
|
485
|
+
}
|
|
486
|
+
|
|
247
487
|
private enqueue(id: PeerId, env: Envelope, front = false): void {
|
|
248
488
|
if (this.withdrawn.has(env.id)) return;
|
|
249
|
-
const queue = this.queues.get(id)
|
|
489
|
+
const queue = this.queues.get(id) ?? [];
|
|
490
|
+
this.queues.set(id, queue);
|
|
250
491
|
if (front) queue.unshift(env);
|
|
251
492
|
else queue.push(env);
|
|
252
493
|
if (queue.length > this.opts.queueCap) {
|
|
253
494
|
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
254
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" });
|
|
255
497
|
this.emit({ t: "overflow", env: lost!, peer: id });
|
|
256
498
|
}
|
|
257
|
-
|
|
499
|
+
this.onQueues?.();
|
|
500
|
+
if (!this.suppressDrain) this.persist();
|
|
501
|
+
if (!this.suppressDrain) void this.drain(id);
|
|
258
502
|
}
|
|
259
503
|
|
|
260
504
|
/** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
|
|
@@ -263,55 +507,80 @@ export class Bus {
|
|
|
263
507
|
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
264
508
|
}
|
|
265
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
|
+
|
|
266
533
|
private async drain(id: PeerId): Promise<void> {
|
|
267
|
-
if (this.draining.has(id)) return;
|
|
534
|
+
if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
|
|
268
535
|
this.draining.add(id);
|
|
269
536
|
try {
|
|
270
537
|
const peer = this.peers.get(id)!;
|
|
271
538
|
const queue = this.queues.get(id)!;
|
|
272
|
-
while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
this.arm(id, wait);
|
|
276
|
-
break;
|
|
277
|
-
}
|
|
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; }
|
|
278
542
|
const preface = this.prefaces.get(id);
|
|
279
|
-
|
|
280
|
-
|
|
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);
|
|
281
547
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
282
|
-
// What goes out may be condensed; what comes back on failure is always the originals. An important envelope
|
|
283
|
-
// is why the queue became ready, so it never waits on a model call.
|
|
284
548
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
285
549
|
this.condensing += mayCondense ? 1 : 0;
|
|
286
550
|
const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
287
551
|
this.condensing -= mayCondense ? 1 : 0;
|
|
288
|
-
if (this.recoveryHeld || (
|
|
289
|
-
|
|
290
|
-
// without counting against the envelopes.
|
|
291
|
-
if (preface) this.prefaces.set(id, preface);
|
|
292
|
-
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))); }
|
|
293
554
|
break;
|
|
294
555
|
}
|
|
295
|
-
|
|
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
|
+
}
|
|
296
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);
|
|
297
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); }
|
|
298
566
|
try {
|
|
299
|
-
await peer.deliver(out);
|
|
567
|
+
if (this.journal) await peer.deliver(out, deliveryId);
|
|
568
|
+
else await peer.deliver(out);
|
|
300
569
|
} catch {
|
|
301
|
-
this.
|
|
570
|
+
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
571
|
+
else this.failed(id, delivery);
|
|
302
572
|
break;
|
|
303
573
|
}
|
|
304
574
|
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
}
|
|
575
|
+
} catch { this.storageError = "delivery journal unavailable"; }
|
|
576
|
+
finally { this.draining.delete(id); this.onQueues?.(); }
|
|
308
577
|
}
|
|
309
578
|
|
|
310
579
|
/**
|
|
311
580
|
* The next delivery: important envelopes first (they are why the queue is ready), then the rest in order.
|
|
312
581
|
* An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
|
|
313
582
|
*/
|
|
314
|
-
private take(id: PeerId, queue: Envelope[]): Envelope[] {
|
|
583
|
+
private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
|
|
315
584
|
const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
|
|
316
585
|
const batch: Envelope[] = [];
|
|
317
586
|
for (const env of ordered) {
|
|
@@ -320,7 +589,7 @@ export class Bus {
|
|
|
320
589
|
batch.push(env);
|
|
321
590
|
if (failedBefore || batch.length === DIGEST_MAX) break;
|
|
322
591
|
}
|
|
323
|
-
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);
|
|
324
593
|
return batch;
|
|
325
594
|
}
|
|
326
595
|
|
|
@@ -347,6 +616,7 @@ export class Bus {
|
|
|
347
616
|
return false;
|
|
348
617
|
});
|
|
349
618
|
this.queues.get(id)!.unshift(...keep);
|
|
619
|
+
this.persist();
|
|
350
620
|
// The idle transition caused by the failure itself may have been swallowed by the `draining` guard.
|
|
351
621
|
this.arm(id, this.opts.retryMs);
|
|
352
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) {
|