@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/src/hub/daemon.ts CHANGED
@@ -27,9 +27,10 @@ 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 { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId } from "./envelope.ts";
33
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";
@@ -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), ...(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 ?? [] } : {}) }]),
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 = () => {
@@ -789,18 +799,37 @@ export async function startDaemon(opts: DaemonOptions) {
789
799
  }
790
800
 
791
801
  function holdPeer(action: "pause" | "resume", id: string) {
792
- 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}` };
793
803
  if (action === "pause") {
804
+ const next = [...new Set([...manualPaused, id])];
805
+ bus.setManualPaused(next);
794
806
  manualPaused.add(id);
795
807
  bus.pause(id);
796
808
  } else {
797
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));
798
811
  manualPaused.delete(id);
799
812
  bus.resume(id);
800
813
  }
801
814
  return { ok: true, state: bus.stateOf(id) };
802
815
  }
803
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
+
804
833
  function uiSnapshot(after: number) {
805
834
  return {
806
835
  ok: true,
@@ -847,7 +876,7 @@ export async function startDaemon(opts: DaemonOptions) {
847
876
  }
848
877
  case "send": {
849
878
  if (!text("body", 8000)) return bad;
850
- 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;
851
880
  const { body, priority } = parseMarker(a.body as string, "important");
852
881
  if (!body) return bad;
853
882
  bus.publish(newEnvelope(USER, body, { priority, ...(Array.isArray(a.to) && a.to.length ? { to: a.to as string[] } : {}) }));
@@ -1026,6 +1055,31 @@ export async function startDaemon(opts: DaemonOptions) {
1026
1055
  }
1027
1056
  if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
1028
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
+ }
1029
1083
  case "recovery":
1030
1084
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
1031
1085
  void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
@@ -1036,10 +1090,11 @@ export async function startDaemon(opts: DaemonOptions) {
1036
1090
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
1037
1091
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
1038
1092
  const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
1039
- const unknown = to?.filter((id) => !bus.peers.has(id)) ?? [];
1093
+ const unknown = to?.filter((id) => !bus.knownPeers().includes(id)) ?? [];
1040
1094
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
1041
1095
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1042
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);
1043
1098
  return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
1044
1099
  }
1045
1100
  case "tail":
@@ -1181,6 +1236,7 @@ export async function startDaemon(opts: DaemonOptions) {
1181
1236
  budget.close();
1182
1237
  board.close();
1183
1238
  server.stop(true);
1239
+ bus.closeJournal();
1184
1240
  try {
1185
1241
  const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
1186
1242
  if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });