@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/src/hub/daemon.ts CHANGED
@@ -27,10 +27,11 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./in
27
27
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
28
28
  import { currentRouting, detectSignals } from "./routing.ts";
29
29
  import { Bus } from "./bus.ts";
30
+ import { DeliveryJournal } from "./delivery-journal.ts";
30
31
  import { startDashboard } from "./ui.ts";
31
32
  import { PROTOCOL, stateDirFor } from "./control-client.ts";
32
- import { newEnvelope, parseMarker, replyParent, USER, type Envelope, type PeerId } from "./envelope.ts";
33
- import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
33
+ import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId } from "./envelope.ts";
34
+ import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
34
35
  import { MemoryClient, workerUrl } from "../memory/client.ts";
35
36
  import { VERSION } from "../version.ts";
36
37
  import { projectChain, recallFor } from "../memory/recall.ts";
@@ -147,10 +148,11 @@ class WsPeer extends BasePeer {
147
148
  this.sock = undefined;
148
149
  this.setState("offline");
149
150
  }
150
- async deliver(envs: Envelope[]): Promise<void> {
151
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
151
152
  if (!this.sock) throw new Error(`${this.id} is offline`);
152
- this.sock.send(JSON.stringify({ t: "deliver", envs }));
153
+ this.sock.send(JSON.stringify({ t: "deliver", envs, ...(deliveryId ? { deliveryId } : {}) }));
153
154
  }
155
+ owns(sock: Sock): boolean { return this.sock === sock; }
154
156
  async start(): Promise<void> {}
155
157
  async stop(): Promise<void> {
156
158
  this.sock?.close();
@@ -186,10 +188,13 @@ export async function startDaemon(opts: DaemonOptions) {
186
188
  },
187
189
  websocket: {
188
190
  message(sock, data) {
191
+ let parsed: any;
189
192
  try {
190
- onMessage(sock, JSON.parse(String(data)));
193
+ parsed = JSON.parse(String(data));
194
+ onMessage(sock, parsed);
191
195
  } catch (e) {
192
- log(`bad control message: ${(e as Error).message}`);
196
+ log("control operation failed; inspect delivery storage and recovery status");
197
+ if (sock.data.authed && Number.isSafeInteger(parsed?.rid)) sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: "operation failed; inspect delivery storage and recovery status before retrying" }));
193
198
  }
194
199
  },
195
200
  close(sock) {
@@ -203,25 +208,29 @@ export async function startDaemon(opts: DaemonOptions) {
203
208
  startupCleanup.push(() => server.stop(true));
204
209
 
205
210
 
211
+ const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
212
+ const restartFilePresent = existsSync(restartPath(opts.stateDir));
213
+ const restored = recoveryOperation
214
+ ? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
215
+ : undefined;
216
+ if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
217
+
206
218
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
207
219
  let inference: Inference | undefined;
208
- const bus = new Bus({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
209
- const manualPaused = new Set<PeerId>(); // `ahub pause`: recovery never lifts these
220
+ const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
221
+ const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
222
+ startupCleanup.push(() => bus.closeJournal());
223
+ const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
210
224
  let recoveryOperationId: string | undefined;
211
225
  let recoveryPhase: RecoveryPhase | undefined;
212
226
  let recoveryCommitted = false;
213
227
  let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
214
228
  let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
215
229
  const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
216
- const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
217
- const restartFilePresent = existsSync(restartPath(opts.stateDir));
218
- const restored = recoveryOperation
219
- ? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
220
- : undefined;
221
- if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
222
230
  if (restored) {
223
- bus.restore(restored.bus);
231
+ bus.restore(restored.bus, restored.operationId);
224
232
  for (const peer of restored.manualPaused) { manualPaused.add(peer); bus.pause(peer); }
233
+ bus.setManualPaused([...manualPaused]);
225
234
  recoveryOperationId = restored.operationId;
226
235
  recoveryPhase = "restored";
227
236
  recoveryPeerSnapshot = restored.peers;
@@ -442,7 +451,7 @@ export async function startDaemon(opts: DaemonOptions) {
442
451
 
443
452
  const pausedNote = (id: PeerId) => {
444
453
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
445
- return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
454
+ return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
446
455
  };
447
456
  const recoveryReady = () => {
448
457
  if (!recoveryActive() || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
@@ -498,11 +507,12 @@ export async function startDaemon(opts: DaemonOptions) {
498
507
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
499
508
  codexProxyPort: opts.codexProxyPort,
500
509
  peers: Object.fromEntries(
501
- [...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]),
510
+ bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
502
511
  ),
503
512
  ...(sidecar ? { switchyard: sidecar.status } : {}),
504
513
  ...(modelRelay ? { models: modelRelay.status() } : {}),
505
514
  tasks: board.counts(),
515
+ ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
506
516
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
507
517
  });
508
518
  const writeStatus = () => {
@@ -511,6 +521,10 @@ export async function startDaemon(opts: DaemonOptions) {
511
521
  renameSync(`${file}.${instanceId}.tmp`, file);
512
522
  };
513
523
 
524
+ // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
525
+ // message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
526
+ bus.onQueues = () => { if (!stopping) writeStatus(); };
527
+
514
528
  bus.tap((e) => {
515
529
  e = redact(e);
516
530
  uiEvents.push({ seq: ++uiSequence, event: e });
@@ -555,7 +569,32 @@ export async function startDaemon(opts: DaemonOptions) {
555
569
  return running;
556
570
  }
557
571
 
572
+ /**
573
+ * Hide a replaced adapter's stop from the console (issue #42): its `offline` would flash into status.json and
574
+ * the dashboard between the two adapters. Returns the undo, which the caller runs on every exit: if no
575
+ * replacement was ever added, the hook goes back on and the truth is emitted, or status keeps reporting a dead
576
+ * peer as idle.
577
+ */
578
+ function muteState(p: PeerAdapter): () => void {
579
+ const hook = p.onState;
580
+ p.onState = undefined;
581
+ return () => {
582
+ if (bus.peers.get(p.id) !== p || p.onState) return; // a replacement took the id: nothing to put back
583
+ p.onState = hook;
584
+ hook?.(p.state);
585
+ };
586
+ }
587
+
558
588
  async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
589
+ let unmute: (() => void) | undefined;
590
+ try {
591
+ return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
592
+ } finally {
593
+ unmute?.();
594
+ }
595
+ }
596
+
597
+ async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
559
598
  if (peer === "pi") {
560
599
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Pi mode" };
561
600
  if (args.backend !== undefined && !["auto", "dgx", "mlx"].includes(args.backend)) return { ok: false, error: "invalid Pi backend" };
@@ -574,11 +613,15 @@ export async function startDaemon(opts: DaemonOptions) {
574
613
  if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
575
614
  // Revoke the previous launch bridge before issuing another launch. A late
576
615
  // process from the abandoned CLI cannot claim the replacement owner.
616
+ // The replacement's start event is the only state change the console should see (issue #42).
617
+ mute(existing);
577
618
  await existing.stop();
578
619
  } else if (changesOwner) {
579
620
  saved = await existing.captureResume();
580
621
  if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
581
622
  args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
623
+ // Same as the unclaimed handover: no offline flash between the adapters (issue #42).
624
+ mute(existing);
582
625
  await existing.stop();
583
626
  } else if (existing.state !== "offline") {
584
627
  return mode === "tui" ? { ok: false, error: "Pi already owns a native terminal; use that terminal or switch to headless first" } : { ok: true, already: true };
@@ -756,18 +799,37 @@ export async function startDaemon(opts: DaemonOptions) {
756
799
  }
757
800
 
758
801
  function holdPeer(action: "pause" | "resume", id: string) {
759
- if (!bus.peers.has(id)) return { ok: false, error: `unknown peer: ${id}` };
802
+ if (!bus.knownPeers().includes(id)) return { ok: false, error: `unknown peer: ${id}` };
760
803
  if (action === "pause") {
804
+ const next = [...new Set([...manualPaused, id])];
805
+ bus.setManualPaused(next);
761
806
  manualPaused.add(id);
762
807
  bus.pause(id);
763
808
  } else {
764
809
  if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
810
+ bus.setManualPaused([...manualPaused].filter((peer) => peer !== id));
765
811
  manualPaused.delete(id);
766
812
  bus.resume(id);
767
813
  }
768
814
  return { ok: true, state: bus.stateOf(id) };
769
815
  }
770
816
 
817
+ // Queue diagnostics are public metadata by default. Private bodies stay behind the existing task console.
818
+ function queueView(row: ReturnType<Bus["queueShow"]>, detail = false) {
819
+ if (!row) return undefined;
820
+ const originals = row.originals;
821
+ const privateDelivery = originals.some((env) => env.private);
822
+ return {
823
+ id: row.id, peer: row.peer, state: row.state, revision: row.revision,
824
+ createdAt: row.createdAt, updatedAt: row.updatedAt,
825
+ envelopeIds: originals.map((env) => env.id),
826
+ important: originals.some((env) => env.priority === "important"),
827
+ ...(detail ? { messages: originals.map((env) => ({ id: env.id, from: env.from, priority: env.priority, kind: env.kind,
828
+ body: privateDelivery ? "[private: inspect the associated task with ahub task show]" : sanitize(env.body),
829
+ })) } : {}),
830
+ };
831
+ }
832
+
771
833
  function uiSnapshot(after: number) {
772
834
  return {
773
835
  ok: true,
@@ -814,7 +876,7 @@ export async function startDaemon(opts: DaemonOptions) {
814
876
  }
815
877
  case "send": {
816
878
  if (!text("body", 8000)) return bad;
817
- if (a.to !== undefined && (!Array.isArray(a.to) || a.to.length > 32 || a.to.some((id) => typeof id !== "string" || !PEER_ID.test(id) || !bus.peers.has(id)))) return bad;
879
+ if (a.to !== undefined && (!Array.isArray(a.to) || a.to.length > 32 || a.to.some((id) => typeof id !== "string" || !PEER_ID.test(id) || !bus.knownPeers().includes(id)))) return bad;
818
880
  const { body, priority } = parseMarker(a.body as string, "important");
819
881
  if (!body) return bad;
820
882
  bus.publish(newEnvelope(USER, body, { priority, ...(Array.isArray(a.to) && a.to.length ? { to: a.to as string[] } : {}) }));
@@ -993,6 +1055,31 @@ export async function startDaemon(opts: DaemonOptions) {
993
1055
  }
994
1056
  if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
995
1057
  switch (msg.t) {
1058
+ case "queue": {
1059
+ if (c.role !== "console") return void reply({ ok: false, error: "queue inspection and resolution are console-only" });
1060
+ if (msg.op === "list") {
1061
+ if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
1062
+ return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
1063
+ }
1064
+ if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
1065
+ if (msg.op === "show") {
1066
+ const row = bus.queueShow(msg.id);
1067
+ return void reply(row ? { ok: true, delivery: queueView(row, true) } : { ok: false, error: "delivery not found" });
1068
+ }
1069
+ if (msg.op !== "resolve" || !["completed", "retry", "discard"].includes(msg.action) || !Number.isSafeInteger(msg.revision) || msg.revision < 0 || typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "resolution needs an action, observed revision and reason" });
1070
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
1071
+ try {
1072
+ bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
1073
+ writeStatus();
1074
+ return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
1075
+ } catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
1076
+ }
1077
+ case "delivery_receipt": {
1078
+ const peer = c.peer ? bus.peers.get(c.peer) : undefined;
1079
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock) || typeof msg.deliveryId !== "string" || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
1080
+ bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
1081
+ return void reply({ ok: true });
1082
+ }
996
1083
  case "recovery":
997
1084
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
998
1085
  void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
@@ -1003,10 +1090,11 @@ export async function startDaemon(opts: DaemonOptions) {
1003
1090
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
1004
1091
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
1005
1092
  const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
1006
- const unknown = to?.filter((id) => !bus.peers.has(id)) ?? [];
1093
+ const unknown = to?.filter((id) => !bus.knownPeers().includes(id)) ?? [];
1007
1094
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
1008
1095
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1009
1096
  const targets = bus.publish(newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) }));
1097
+ if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id);
1010
1098
  return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
1011
1099
  }
1012
1100
  case "tail":
@@ -1148,6 +1236,7 @@ export async function startDaemon(opts: DaemonOptions) {
1148
1236
  budget.close();
1149
1237
  board.close();
1150
1238
  server.stop(true);
1239
+ bus.closeJournal();
1151
1240
  try {
1152
1241
  const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
1153
1242
  if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });