@staix/agent-hub 0.3.2 → 0.5.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/budget.ts CHANGED
@@ -76,6 +76,9 @@ export class Budget {
76
76
  /** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
77
77
  private readonly ignoreUntil = new Map<PeerId, number>();
78
78
  private closed = false;
79
+ private recoveryHeld = false;
80
+ private deferred = false;
81
+ private readonly deferredHard = new Set<PeerId>();
79
82
 
80
83
  constructor(
81
84
  dbPath: string,
@@ -88,11 +91,33 @@ export class Budget {
88
91
  reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
89
92
  }
90
93
 
94
+ /** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
95
+ setRecoveryHold(held: boolean): void {
96
+ this.recoveryHeld = held;
97
+ if (!held && this.deferred && !this.closed) {
98
+ this.deferred = false;
99
+ this.tick();
100
+ for (const peer of this.readings.keys()) {
101
+ const hard = this.deferredHard.delete(peer);
102
+ void this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
103
+ }
104
+ }
105
+ }
106
+
107
+ get recoverySettled(): boolean {
108
+ return this.pausing.size === 0 && this.handingOff.size === 0;
109
+ }
110
+
91
111
  record(peer: PeerId): PauseRecord | undefined {
92
112
  const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
93
113
  return r ? { peer: r.peer, since: r.since, resetsAt: r.resets_at, reason: r.reason, summary: r.summary, handedOff: !!r.handed_off, moved: JSON.parse(r.moved) } : undefined;
94
114
  }
95
115
 
116
+ /** Recovery integrity input: persisted pause records only, excluding live usage readings and private summaries. */
117
+ persistedPauseDigestRows(): { peer: PeerId; since: number; resetsAt: number; reason: string }[] {
118
+ return this.records().map(({ peer, since, resetsAt, reason }) => ({ peer, since, resetsAt, reason }));
119
+ }
120
+
96
121
  private records(): PauseRecord[] {
97
122
  return (this.db.query("SELECT peer FROM budget_pauses").all() as { peer: string }[]).map((r) => this.record(r.peer)!);
98
123
  }
@@ -122,6 +147,7 @@ export class Budget {
122
147
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
123
148
  this.readings.set(peer, mine);
124
149
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
150
+ if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
125
151
  this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
126
152
  }
127
153
 
@@ -141,6 +167,7 @@ export class Budget {
141
167
  }
142
168
 
143
169
  private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
170
+ if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
144
171
  const fresh = this.fresh(peer);
145
172
  const over = fresh.filter((r) => r.used >= this.cfg.gate);
146
173
  const open = this.record(peer);
@@ -160,6 +187,7 @@ export class Budget {
160
187
  const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
161
188
  // Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
162
189
  const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
190
+ if (this.recoveryHeld) { this.deferred = true; return; }
163
191
  if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
164
192
  this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
165
193
  this.hooks.pause(peer);
@@ -171,10 +199,12 @@ export class Budget {
171
199
  }
172
200
 
173
201
  private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
174
- if (this.closed || this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
202
+ if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
203
+ if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
175
204
  this.handingOff.add(peer);
176
205
  try {
177
206
  await this.handOff(peer, summary);
207
+ if (this.recoveryHeld) { this.deferred = true; return; }
178
208
  } finally {
179
209
  this.handingOff.delete(peer);
180
210
  }
@@ -202,6 +232,7 @@ export class Budget {
202
232
 
203
233
  /** Called on a timer. Resumes every peer whose window has reset. */
204
234
  tick(): void {
235
+ if (this.recoveryHeld) { this.deferred = true; return; }
205
236
  for (const r of this.records()) {
206
237
  if (this.now() >= r.resetsAt + 60_000) {
207
238
  if (this.hooks.attached(r.peer)) this.resume(r.peer, "the window has reset"); // else: wait, the notice needs somebody to receive it
package/src/hub/bus.ts CHANGED
@@ -17,6 +17,17 @@ export interface BusOptions {
17
17
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
18
18
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
19
19
  }
20
+
21
+ /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
22
+ export interface BusSnapshot {
23
+ schemaVersion: 1;
24
+ queues: Record<string, Envelope[]>;
25
+ prefaces: Record<string, Envelope>;
26
+ seen: Envelope[];
27
+ attempts: Record<string, number>;
28
+ withdrawn: string[];
29
+ }
30
+
20
31
  export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
21
32
 
22
33
  const SEEN_CAP = 2048;
@@ -42,6 +53,9 @@ export class Bus {
42
53
  /** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
43
54
  private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
44
55
  private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
56
+ private recoveryHeld = false;
57
+ private steering = 0;
58
+ private condensing = 0;
45
59
 
46
60
  constructor(opts: Partial<BusOptions> = {}) {
47
61
  this.opts = { ...DEFAULT_BUS, ...opts };
@@ -66,6 +80,57 @@ export class Bus {
66
80
  };
67
81
  }
68
82
 
83
+ /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
84
+ setRecoveryHold(held: boolean): void {
85
+ this.recoveryHeld = held;
86
+ if (!held) for (const id of this.peers.keys()) void this.drain(id);
87
+ }
88
+
89
+ get isRecoveryHeld(): boolean {
90
+ return this.recoveryHeld;
91
+ }
92
+
93
+ /** Wait until a hold has fenced condensation, steering and queue drains already in flight. */
94
+ async fenceRecovery(): Promise<void> {
95
+ this.recoveryHeld = true;
96
+ while (this.draining.size || this.steering || this.condensing) await Bun.sleep(0);
97
+ }
98
+
99
+ /** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
100
+ snapshot(): BusSnapshot {
101
+ return {
102
+ schemaVersion: 1,
103
+ queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
104
+ prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
105
+ seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
106
+ attempts: Object.fromEntries(this.attempts),
107
+ withdrawn: [...this.withdrawn],
108
+ };
109
+ }
110
+
111
+ /** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
112
+ restore(snapshot: BusSnapshot): void {
113
+ if (snapshot.schemaVersion !== 1) throw new Error("unsupported bus recovery snapshot");
114
+ this.queues.clear();
115
+ for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
116
+ this.prefaces.clear();
117
+ for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
118
+ this.seen.clear();
119
+ for (const e of snapshot.seen ?? []) this.seen.set(e.id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
120
+ this.attempts.clear();
121
+ for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
122
+ this.withdrawn.clear();
123
+ for (const id of snapshot.withdrawn ?? []) this.withdrawn.add(id);
124
+ }
125
+
126
+ queueIds(id: PeerId): string[] {
127
+ return (this.queues.get(id) ?? []).map((e) => e.id);
128
+ }
129
+
130
+ attemptState(): Record<string, number> {
131
+ return Object.fromEntries(this.attempts);
132
+ }
133
+
69
134
  tap(fn: (e: BusEvent) => void): () => void {
70
135
  this.taps.add(fn);
71
136
  return () => this.taps.delete(fn);
@@ -90,6 +155,13 @@ export class Bus {
90
155
 
91
156
  /** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
92
157
  preface(id: PeerId, body: string): void {
158
+ const existing = this.prefaces.get(id);
159
+ if (existing) {
160
+ // Recovery and session recall may both contribute context. Keep the original id/hop so a queued
161
+ // preface remains deduplicated and append the new, separately generated context.
162
+ this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
163
+ return;
164
+ }
93
165
  this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
94
166
  }
95
167
 
@@ -107,9 +179,10 @@ export class Bus {
107
179
  const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
108
180
  for (const id of targets) {
109
181
  const peer = this.peers.get(id)!;
110
- if (env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
182
+ if (!this.recoveryHeld && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
111
183
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
112
- peer.steer([env]).catch(() => this.enqueue(id, env, true));
184
+ this.steering++;
185
+ peer.steer([env]).catch(() => this.enqueue(id, env, true)).finally(() => { this.steering--; });
113
186
  } else this.enqueue(id, env);
114
187
  }
115
188
  return targets;
@@ -165,7 +238,7 @@ export class Bus {
165
238
  try {
166
239
  const peer = this.peers.get(id)!;
167
240
  const queue = this.queues.get(id)!;
168
- while (queue.length && this.stateOf(id) === "idle") {
241
+ while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
169
242
  const wait = this.wait(queue);
170
243
  if (wait > 0) {
171
244
  this.arm(id, wait);
@@ -178,8 +251,10 @@ export class Bus {
178
251
  // What goes out may be condensed; what comes back on failure is always the originals. An important envelope
179
252
  // is why the queue became ready, so it never waits on a model call.
180
253
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
254
+ this.condensing += mayCondense ? 1 : 0;
181
255
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
182
- if (mayCondense && this.stateOf(id) !== "idle") {
256
+ this.condensing -= mayCondense ? 1 : 0;
257
+ if (this.recoveryHeld || (mayCondense && this.stateOf(id) !== "idle")) {
183
258
  // The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
184
259
  // without counting against the envelopes.
185
260
  if (preface) this.prefaces.set(id, preface);
@@ -0,0 +1,55 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ /**
4
+ * Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
5
+ * agents and their long-lived children must never inherit it, or their later
6
+ * lifecycle calls could bypass the machine recovery lock.
7
+ */
8
+ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
9
+ const env = { ...source };
10
+ delete env.AGENTHUB_RECOVERY_OPERATION;
11
+ return env;
12
+ }
13
+
14
+ /**
15
+ * Stop a child owned by this hub and confirm that it exited. A timeout is an
16
+ * incomplete shutdown, even after SIGKILL, because callers must not reuse a
17
+ * port or claim ownership while the old process may still be alive.
18
+ */
19
+ export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killMs = 2_000): Promise<void> {
20
+ if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) return;
21
+
22
+ const waitForExit = (timeoutMs: number): Promise<boolean> =>
23
+ new Promise((resolve) => {
24
+ let timer: ReturnType<typeof setTimeout> | undefined;
25
+ const done = (exited: boolean) => {
26
+ if (timer) clearTimeout(timer);
27
+ proc.off("exit", onExit);
28
+ proc.off("error", onError);
29
+ resolve(exited);
30
+ };
31
+ const onExit = () => done(true);
32
+ const onError = () => done(true); // spawn errors have no owned process to wait for
33
+ proc.once("exit", onExit);
34
+ proc.once("error", onError);
35
+ timer = setTimeout(() => done(false), timeoutMs);
36
+ timer.unref?.();
37
+ });
38
+
39
+ try {
40
+ proc.kill("SIGTERM");
41
+ } catch {
42
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
43
+ throw new Error(`owned child ${proc.pid} could not be terminated`);
44
+ }
45
+ if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(termMs))) return;
46
+
47
+ try {
48
+ proc.kill("SIGKILL");
49
+ } catch {
50
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
51
+ throw new Error(`owned child ${proc.pid} could not be killed`);
52
+ }
53
+ if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(killMs))) return;
54
+ throw new Error(`owned child ${proc.pid} shutdown incomplete after SIGKILL`);
55
+ }
@@ -1,25 +1,41 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { projectContext } from "./project.ts";
3
4
 
4
5
  export function stateDirFor(cwd: string): string {
5
- return process.env.AGENTHUB_STATE_DIR ?? join(cwd, ".agenthub", "state");
6
+ return projectContext(cwd).stateDir;
6
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. The plugin is installed apart from the daemon, so they can drift. */
9
- export const PROTOCOL = 6;
10
+ export const PROTOCOL = 8; // controlled daemon restart/recovery RPC and project/instance identity
10
11
 
11
12
  export interface Hello {
12
13
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
13
14
  role: "peer" | "console" | "tools";
14
15
  peer?: string;
16
+ projectId?: string;
17
+ instanceId?: string;
18
+ projectRoot?: string;
19
+ }
20
+
21
+ export interface ControlDescriptor {
22
+ url: string;
23
+ token: string;
24
+ projectId?: string;
25
+ instanceId?: string;
26
+ cwd?: string;
27
+ protocol?: number;
28
+ pid?: number;
15
29
  }
16
30
 
17
31
  /** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
18
- export function readControl(stateDir: string): { url: string; token: string } | undefined {
32
+ export function readControl(stateDir: string): ControlDescriptor | undefined {
19
33
  try {
20
34
  const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
21
35
  const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
22
- return { url: `ws://127.0.0.1:${status.controlPort}`, token };
36
+ if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
37
+ return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
38
+ instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid };
23
39
  } catch {
24
40
  return undefined;
25
41
  }
@@ -35,14 +51,27 @@ export class ControlClient {
35
51
 
36
52
  private constructor(private readonly ws: WebSocket) {}
37
53
 
38
- static connect(stateDir: string, hello: Hello): Promise<ControlClient> {
54
+ static connect(stateDir: string, hello: Hello, timeoutMs = 3000): Promise<ControlClient> {
39
55
  const control = readControl(stateDir);
40
56
  if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
57
+ if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
58
+ return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
59
+ }
60
+ if ((hello.projectId && hello.projectId !== control.projectId) ||
61
+ (hello.instanceId && hello.instanceId !== control.instanceId) ||
62
+ (hello.projectRoot && hello.projectRoot !== control.cwd)) {
63
+ return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
64
+ }
65
+ const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
66
+ projectRoot: hello.projectRoot ?? control.cwd };
41
67
  return new Promise((resolve, reject) => {
42
68
  const ws = new WebSocket(control.url);
43
69
  const client = new ControlClient(ws);
44
- ws.onerror = () => reject(new Error(`cannot reach hub at ${control.url}`));
70
+ const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
71
+ const refuse = (error: Error) => { clearTimeout(timer); reject(error); ws.close(); };
72
+ ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
45
73
  ws.onclose = (ev) => {
74
+ clearTimeout(timer);
46
75
  // A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
47
76
  reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
48
77
  for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
@@ -50,18 +79,30 @@ export class ControlClient {
50
79
  client.onClose(ev.code, ev.reason);
51
80
  };
52
81
  ws.onmessage = (ev) => {
53
- const msg = JSON.parse(String(ev.data));
82
+ let msg: any;
83
+ try { msg = JSON.parse(String(ev.data)); } catch { return refuse(new Error("invalid hub response")); }
84
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(new Error("invalid hub response"));
54
85
  const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
55
86
  if (!done) return client.onPush(msg);
56
87
  client.pending.delete(msg.rid);
57
88
  done(msg);
58
89
  };
59
- ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() => resolve(client));
90
+ ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
91
+ if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
92
+ if ((expected.projectId && reply.projectId !== expected.projectId) ||
93
+ (expected.instanceId && reply.instanceId !== expected.instanceId) ||
94
+ (expected.projectRoot && reply.cwd !== expected.projectRoot)) {
95
+ return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
96
+ }
97
+ clearTimeout(timer);
98
+ resolve(client);
99
+ });
60
100
  });
61
101
  }
62
102
 
63
103
  /** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
64
- request(msg: Record<string, unknown>, timeoutMs?: number): Promise<any> {
104
+ request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
105
+ if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
65
106
  const rid = this.nextRid++;
66
107
  return new Promise((resolve) => {
67
108
  const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;