@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.
@@ -3,7 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
- import { ControlClient, PROTOCOL } from "./control-client.ts";
6
+ import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
7
7
  import { Registry, type Project } from "./registry.ts";
8
8
  import { hubHome } from "./project.ts";
9
9
  import { startDashboard } from "./ui.ts";
@@ -21,7 +21,9 @@ type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
21
21
  type Manifest = { port: number; protocol: number; instanceId: string; pid: number };
22
22
  const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
23
23
  const CONTROL_TIMEOUT = 3000;
24
- const SUPPORTED_MANAGER_PROTOCOLS = new Set([8, PROTOCOL]);
24
+ // A current CLI may authenticate the previous protocol-9 manager while
25
+ // refreshing it, then launch the protocol-10 manager implementation.
26
+ const SUPPORTED_MANAGER_PROTOCOLS = new Set<number>(RECOVERY_SOURCE_PROTOCOLS);
25
27
  const safeError = (error: unknown) => error instanceof Error ? error.message.slice(0, 300) : "operation failed";
26
28
 
27
29
  function files(home: string) {
@@ -216,11 +218,11 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
216
218
  throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
217
219
  }
218
220
 
219
- /** Refresh an explicitly supported protocol-8 manager onto the current CLI after upgrade. */
221
+ /** Refresh an explicitly supported protocol-9 manager onto the current CLI after upgrade. */
220
222
  export async function refreshManager(options: ManagerOptions = {}): Promise<string> {
221
223
  const home = options.home ?? hubHome();
222
224
  const manifest = readManifest(home);
223
- if (manifest && manifest.protocol === 8) {
225
+ if (manifest && manifest.protocol === RECOVERY_SOURCE_PROTOCOLS[0]) {
224
226
  await managerRequest(home, "/stop");
225
227
  const deadline = Date.now() + CONTROL_TIMEOUT;
226
228
  while (Date.now() < deadline && readManifest(home)) await Bun.sleep(25);
package/src/hub/peers.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
2
 
3
+ export interface DeliveryReceipt {
4
+ id: string;
5
+ state: "accepted" | "completed" | "failed_safe" | "needs_review";
6
+ reason?: string;
7
+ }
8
+
3
9
  export interface PeerAdapter {
4
10
  readonly id: PeerId;
5
11
  readonly state: PeerState;
6
12
  /** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
7
13
  readonly hubNative?: boolean;
8
14
  /** Inject now, as one prompt. Only called while `state === "idle"`. Rejecting puts the envelopes back at the queue head. */
9
- deliver(envs: Envelope[]): Promise<void>;
15
+ deliver(envs: Envelope[], deliveryId?: string): Promise<void>;
10
16
  /** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
11
- steer?(envs: Envelope[]): Promise<void>;
17
+ steer?(envs: Envelope[], deliveryId?: string): Promise<void>;
12
18
  start(): Promise<void>;
13
19
  stop(): Promise<void>;
14
20
  /** Set by the bus. The peer said something worth sharing. */
@@ -17,6 +23,8 @@ export interface PeerAdapter {
17
23
  onState?: (state: PeerState) => void;
18
24
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
19
25
  onFailed?: (envs: Envelope[]) => void;
26
+ /** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
27
+ onDelivery?: (receipt: DeliveryReceipt) => void;
20
28
  /** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
21
29
  recoveryMetadata?(): Record<string, unknown>;
22
30
  }
@@ -28,6 +36,7 @@ export abstract class BasePeer implements PeerAdapter {
28
36
  onMessage?: (body: string, opts?: EnvelopeOpts) => void;
29
37
  onState?: (state: PeerState) => void;
30
38
  onFailed?: (envs: Envelope[]) => void;
39
+ onDelivery?: (receipt: DeliveryReceipt) => void;
31
40
  private _state: PeerState = "offline";
32
41
  private timer: ReturnType<typeof setTimeout> | undefined;
33
42
 
@@ -61,12 +70,16 @@ export abstract class BasePeer implements PeerAdapter {
61
70
  this.setState("idle");
62
71
  }
63
72
 
73
+ protected delivery(receipt: DeliveryReceipt): void {
74
+ this.onDelivery?.(receipt);
75
+ }
76
+
64
77
  private clearWatchdog(): void {
65
78
  if (this.timer) clearTimeout(this.timer);
66
79
  this.timer = undefined;
67
80
  }
68
81
 
69
- abstract deliver(envs: Envelope[]): Promise<void>;
82
+ abstract deliver(envs: Envelope[], deliveryId?: string): Promise<void>;
70
83
  abstract start(): Promise<void>;
71
84
  abstract stop(): Promise<void>;
72
85
  }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { PeerId, PeerState } from "./envelope.ts";
6
6
  import { MAX_HOP } from "./envelope.ts";
7
+ import { validateJournalSnapshot } from "./delivery-journal.ts";
7
8
 
8
9
  export const RESTART_SCHEMA_VERSION = 1;
9
10
 
@@ -71,6 +72,7 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
71
72
  if (!Object.keys(bus.queues).every(peerId) || !Object.keys(bus.prefaces).every(peerId) || !Object.values(bus.queues).every((queue) => Array.isArray(queue) && queue.every((e) => isEnvelope(e))) || !Object.values(bus.prefaces).every((e) => isEnvelope(e)) || !bus.seen.every((e) => isEnvelope(e, true)) || !bus.withdrawn.every((id) => typeof id === "string" && id.length > 0) || !Object.values(bus.attempts).every((n) => Number.isSafeInteger(n) && n > 0)) return undefined;
72
73
  if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
73
74
  if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
75
+ if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
74
76
  return s as RestartSnapshot;
75
77
  }
76
78
 
package/src/ui/index.html CHANGED
@@ -155,7 +155,10 @@ function render(snapshot) {
155
155
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
156
156
  name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
157
157
  head.append(name, badge(peer.state, peerDot[peer.state] || ''));
158
- row.append(head, el('p', peer.queued + ' queued', 'meta'));
158
+ row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
159
+ if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
160
+ if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
161
+ if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
159
162
  if (peer.paused) row.append(el('p', peer.paused, 'meta'));
160
163
  if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
161
164
  for (const backend of peer.backends || []) {
@@ -165,8 +168,9 @@ function render(snapshot) {
165
168
  row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
166
169
  }
167
170
  });
168
- const peerSignature = JSON.stringify(ids);
169
- if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), ids, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
171
+ const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
172
+ const peerSignature = JSON.stringify([ids, assignable]);
173
+ if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
170
174
  $('task-count').textContent = snapshot.tasks.length + ' tasks';
171
175
  const taskFilter = $('task-filter').value;
172
176
  const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;