@staix/agent-hub 0.4.0 → 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/CHANGELOG.md +13 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +79 -11
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +580 -0
- package/src/cli/upgrade-runtime.ts +299 -0
- package/src/cli/upgrade.ts +295 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +11 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +227 -3
- package/src/hub/lifecycle.ts +9 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/restart.ts +97 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
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
|
+
|
|
3
14
|
/**
|
|
4
15
|
* Stop a child owned by this hub and confirm that it exited. A timeout is an
|
|
5
16
|
* incomplete shutdown, even after SIGKILL, because callers must not reuse a
|
|
@@ -7,7 +7,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
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. */
|
|
10
|
-
export const PROTOCOL =
|
|
10
|
+
export const PROTOCOL = 8; // controlled daemon restart/recovery RPC and project/instance identity
|
|
11
11
|
|
|
12
12
|
export interface Hello {
|
|
13
13
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
package/src/hub/daemon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { ServerWebSocket } from "bun";
|
|
5
5
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
@@ -28,6 +28,7 @@ import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
|
|
|
28
28
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
29
29
|
import { VERSION } from "../version.ts";
|
|
30
30
|
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
31
|
+
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
31
32
|
|
|
32
33
|
export interface HubConfig {
|
|
33
34
|
watchdog_ms: number;
|
|
@@ -134,6 +135,10 @@ class WsPeer extends BasePeer {
|
|
|
134
135
|
async stop(): Promise<void> {
|
|
135
136
|
this.sock?.close();
|
|
136
137
|
}
|
|
138
|
+
|
|
139
|
+
recoveryMetadata(): Record<string, unknown> {
|
|
140
|
+
return { launch: { kind: "claude-channel", peer: this.id } };
|
|
141
|
+
}
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
export async function startDaemon(opts: DaemonOptions) {
|
|
@@ -181,6 +186,43 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
181
186
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
182
187
|
let inference: Inference | undefined;
|
|
183
188
|
const bus = new Bus({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
|
|
189
|
+
const manualPaused = new Set<PeerId>(); // `ahub pause`: recovery never lifts these
|
|
190
|
+
let recoveryOperationId: string | undefined;
|
|
191
|
+
let recoveryPhase: RecoveryPhase | undefined;
|
|
192
|
+
let recoveryCommitted = false;
|
|
193
|
+
let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
|
|
194
|
+
let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
195
|
+
const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
|
|
196
|
+
const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
|
|
197
|
+
const restartFilePresent = existsSync(restartPath(opts.stateDir));
|
|
198
|
+
const restored = recoveryOperation
|
|
199
|
+
? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
|
|
200
|
+
: undefined;
|
|
201
|
+
if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
|
|
202
|
+
if (restored) {
|
|
203
|
+
bus.restore(restored.bus);
|
|
204
|
+
for (const peer of restored.manualPaused) { manualPaused.add(peer); bus.pause(peer); }
|
|
205
|
+
recoveryOperationId = restored.operationId;
|
|
206
|
+
recoveryPhase = "restored";
|
|
207
|
+
recoveryPeerSnapshot = restored.peers;
|
|
208
|
+
bus.setRecoveryHold(true);
|
|
209
|
+
}
|
|
210
|
+
const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
|
|
211
|
+
const armRecoveryLease = () => {
|
|
212
|
+
clearTimeout(recoveryLeaseTimer);
|
|
213
|
+
recoveryLeaseTimer = setTimeout(() => {
|
|
214
|
+
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
|
|
215
|
+
log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
|
|
216
|
+
recoveryOperationId = undefined;
|
|
217
|
+
recoveryPhase = undefined;
|
|
218
|
+
recoveryPeerSnapshot = undefined;
|
|
219
|
+
bus.setRecoveryHold(false);
|
|
220
|
+
budget.setRecoveryHold(false);
|
|
221
|
+
removeRestartSnapshot(opts.stateDir);
|
|
222
|
+
writeStatus();
|
|
223
|
+
}, 10 * 60_000);
|
|
224
|
+
recoveryLeaseTimer.unref?.();
|
|
225
|
+
};
|
|
184
226
|
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
185
227
|
const chain = projectChain(opts.cwd);
|
|
186
228
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
@@ -227,7 +269,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
227
269
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
228
270
|
});
|
|
229
271
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
230
|
-
const manualPaused = new Set<PeerId>(); // `ahub pause`: the coordinator never lifts these
|
|
231
272
|
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
232
273
|
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
233
274
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
@@ -270,6 +311,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
270
311
|
},
|
|
271
312
|
notify: (line) => notify(line),
|
|
272
313
|
});
|
|
314
|
+
budget.setRecoveryHold(recoveryActive());
|
|
273
315
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
274
316
|
startupCleanup.push(() => budget.close());
|
|
275
317
|
let kimiSessionTotal = 0;
|
|
@@ -363,6 +405,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
363
405
|
}
|
|
364
406
|
throw new Error(`unknown task operation ${op}`);
|
|
365
407
|
}
|
|
408
|
+
function recoveryTaskPreface(peer: PeerId): void {
|
|
409
|
+
if (recoveryPhase !== "restored") return;
|
|
410
|
+
const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !["approved"].includes(task.state));
|
|
411
|
+
if (!open.length) return;
|
|
412
|
+
const lines = open.map((task) => {
|
|
413
|
+
const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
|
|
414
|
+
return `#${task.id} ${String(view.title ?? "[private]")} (${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"})${view.detail && view.detail !== "[pii]" ? `\n${String(view.detail).slice(0, 1000)}` : ""}`;
|
|
415
|
+
});
|
|
416
|
+
bus.preface(peer, `Controlled restart restored your open task context. Check the board before acting:\n${lines.join("\n\n")}`);
|
|
417
|
+
}
|
|
366
418
|
const permissions = new Map<string, { push: string; done: (optionId: string | undefined) => void }>();
|
|
367
419
|
let stopping = false;
|
|
368
420
|
|
|
@@ -370,6 +422,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
370
422
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
371
423
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
372
424
|
};
|
|
425
|
+
const recoveryReady = () => {
|
|
426
|
+
if (!recoveryActive() || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy")) return false;
|
|
427
|
+
if (!recoveryPeerSnapshot) return true;
|
|
428
|
+
const current = recoveryPeers();
|
|
429
|
+
return recoveryPeerSnapshot.every((saved) => {
|
|
430
|
+
const now = current[saved.id];
|
|
431
|
+
if (!now) return true; // a detached peer is checked by the coordinator before terminal close
|
|
432
|
+
if (saved.threadId && now.threadId && saved.threadId !== now.threadId) return false;
|
|
433
|
+
if (saved.sessionId && now.sessionId && saved.sessionId !== now.sessionId) return false;
|
|
434
|
+
return true;
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
const claudeSessionId = () => {
|
|
438
|
+
try {
|
|
439
|
+
const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
|
|
440
|
+
if (value.instanceId !== instanceId) return undefined;
|
|
441
|
+
try {
|
|
442
|
+
const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
443
|
+
const current = Array.isArray(records) ? records.find((row) => row?.peer === "claude" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId) : undefined;
|
|
444
|
+
if (current?.launchId && value.launchId !== current.launchId) return undefined;
|
|
445
|
+
} catch { /* no managed terminal record: the instance fence is still enforced */ }
|
|
446
|
+
return typeof value.sessionId === "string" && value.sessionId ? value.sessionId : undefined;
|
|
447
|
+
} catch { return undefined; }
|
|
448
|
+
};
|
|
449
|
+
const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
|
|
450
|
+
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
451
|
+
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
452
|
+
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
453
|
+
const sessionId = id === "claude" ? claudeSessionId() : metadata.sessionId;
|
|
454
|
+
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
455
|
+
return [id, row];
|
|
456
|
+
}));
|
|
457
|
+
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
458
|
+
const integrity = () => {
|
|
459
|
+
const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
460
|
+
const boardState = board.list().sort((a, b) => a.id - b.id);
|
|
461
|
+
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
462
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
|
|
463
|
+
};
|
|
373
464
|
const status = () => ({
|
|
374
465
|
projectId,
|
|
375
466
|
instanceId,
|
|
@@ -386,6 +477,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
386
477
|
),
|
|
387
478
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
388
479
|
tasks: board.counts(),
|
|
480
|
+
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
389
481
|
});
|
|
390
482
|
const writeStatus = () => {
|
|
391
483
|
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
@@ -447,6 +539,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
447
539
|
const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_cmd;
|
|
448
540
|
const kimi = new AcpPeer("kimi", {
|
|
449
541
|
cmd,
|
|
542
|
+
...(args.model ? { launchModel: args.model } : {}),
|
|
450
543
|
cwd: opts.cwd,
|
|
451
544
|
watchdogMs: config.watchdog_ms,
|
|
452
545
|
onPermission,
|
|
@@ -455,6 +548,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
455
548
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
456
549
|
preamble: roleContract("kimi", config.roles),
|
|
457
550
|
});
|
|
551
|
+
recoveryTaskPreface("kimi");
|
|
458
552
|
await ensurePreface("kimi");
|
|
459
553
|
bus.add(kimi);
|
|
460
554
|
await kimi.start();
|
|
@@ -480,6 +574,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
480
574
|
watchdogMs: config.watchdog_ms,
|
|
481
575
|
log,
|
|
482
576
|
});
|
|
577
|
+
recoveryTaskPreface("codex");
|
|
483
578
|
await ensurePreface("codex");
|
|
484
579
|
bus.add(codex);
|
|
485
580
|
await codex.start();
|
|
@@ -526,6 +621,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
526
621
|
maxSteps: config.local.max_steps,
|
|
527
622
|
log,
|
|
528
623
|
});
|
|
624
|
+
recoveryTaskPreface("local");
|
|
529
625
|
await ensurePreface("local");
|
|
530
626
|
bus.add(local);
|
|
531
627
|
await local.start();
|
|
@@ -611,6 +707,122 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
611
707
|
}
|
|
612
708
|
}
|
|
613
709
|
|
|
710
|
+
const recoveryView = () => ({
|
|
711
|
+
operationId: recoveryOperationId,
|
|
712
|
+
phase: recoveryPhase,
|
|
713
|
+
ready: recoveryReady(),
|
|
714
|
+
pendingApprovals: permissions.size,
|
|
715
|
+
capabilities: { controlledRestart: true, snapshotSchemaVersion: 1, maxLeaseMs: 10 * 60_000 },
|
|
716
|
+
blockers: [
|
|
717
|
+
...(starting.size ? ["peer startup in progress"] : []),
|
|
718
|
+
...(!budget.recoverySettled ? ["budget transition in progress"] : []),
|
|
719
|
+
...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
|
|
720
|
+
...(permissions.size ? ["pending approvals"] : []),
|
|
721
|
+
],
|
|
722
|
+
integrity: { current: integrity(), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
723
|
+
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
724
|
+
const now = recoveryPeers()[peer.id];
|
|
725
|
+
if (recoveryPhase === "restored" || recoveryPhase === "released") return [peer.id, now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }];
|
|
726
|
+
return [peer.id, now ? { ...peer, ...now, queueIds: now.queueIds } : peer];
|
|
727
|
+
})),
|
|
728
|
+
});
|
|
729
|
+
const recoveryError = (error: string) => ({ t: "recovery", ok: false, error });
|
|
730
|
+
async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
|
|
731
|
+
if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
|
|
732
|
+
if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
|
|
733
|
+
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
734
|
+
if (typeof msg.operationId !== "string" || msg.operationId.length < 1 || msg.operationId.length > 128) return recoveryError("operationId is required");
|
|
735
|
+
const op = msg.operationId as string;
|
|
736
|
+
if (msg.op === "prepare") {
|
|
737
|
+
if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
|
|
738
|
+
if (recoveryPhase === "released" && recoveryOperationId !== op) {
|
|
739
|
+
recoveryOperationId = undefined;
|
|
740
|
+
recoveryPhase = undefined;
|
|
741
|
+
recoveryPeerSnapshot = undefined;
|
|
742
|
+
}
|
|
743
|
+
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
744
|
+
recoveryOperationId = op;
|
|
745
|
+
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
746
|
+
armRecoveryLease();
|
|
747
|
+
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
748
|
+
budget.setRecoveryHold(true);
|
|
749
|
+
bus.setRecoveryHold(true);
|
|
750
|
+
await bus.fenceRecovery();
|
|
751
|
+
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
752
|
+
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
753
|
+
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
754
|
+
recoveryPhase = "prepared";
|
|
755
|
+
}
|
|
756
|
+
writeStatus();
|
|
757
|
+
return { t: "recovery", ok: true, recovery: { ...recoveryView(), ...(blocked.length ? { blockedPeers: blocked } : {}), ...(permissions.size ? { blocked: "pending approvals" } : {}) } };
|
|
758
|
+
}
|
|
759
|
+
if (!recoveryOperationId || recoveryOperationId !== op) return msg.op === "inspect"
|
|
760
|
+
? { t: "recovery", ok: true, recovery: recoveryView() }
|
|
761
|
+
: recoveryError("operation does not match this daemon");
|
|
762
|
+
if (msg.op === "inspect") return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
763
|
+
if (msg.op === "abort") {
|
|
764
|
+
if (recoveryCommitted) return recoveryError("a committed recovery cannot be aborted");
|
|
765
|
+
if (recoveryPhase === "released") return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
766
|
+
recoveryOperationId = undefined;
|
|
767
|
+
recoveryPhase = undefined;
|
|
768
|
+
recoveryPeerSnapshot = undefined;
|
|
769
|
+
clearTimeout(recoveryLeaseTimer);
|
|
770
|
+
bus.setRecoveryHold(false);
|
|
771
|
+
budget.setRecoveryHold(false);
|
|
772
|
+
writeStatus();
|
|
773
|
+
return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
|
|
774
|
+
}
|
|
775
|
+
if (msg.op === "commit") {
|
|
776
|
+
if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle and approvals are complete");
|
|
777
|
+
recoveryPhase = "prepared";
|
|
778
|
+
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
779
|
+
const currentPeers = recoveryPeers();
|
|
780
|
+
const peers = (recoveryPeerSnapshot ?? Object.values(currentPeers)).map((saved) => ({
|
|
781
|
+
...saved,
|
|
782
|
+
queueIds: bus.queueIds(saved.id),
|
|
783
|
+
}));
|
|
784
|
+
const snapshot: RestartSnapshot = {
|
|
785
|
+
schemaVersion: 1,
|
|
786
|
+
projectRoot: opts.cwd,
|
|
787
|
+
projectId,
|
|
788
|
+
sourceInstanceId: instanceId,
|
|
789
|
+
operationId: op,
|
|
790
|
+
committedAt: Date.now(),
|
|
791
|
+
bus: bus.snapshot(),
|
|
792
|
+
manualPaused: [...manualPaused],
|
|
793
|
+
peers,
|
|
794
|
+
integrity: integrity(),
|
|
795
|
+
};
|
|
796
|
+
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
797
|
+
recoveryCommitted = true;
|
|
798
|
+
clearTimeout(recoveryLeaseTimer);
|
|
799
|
+
writeStatus();
|
|
800
|
+
// Keep phase prepared in status until the replacement daemon reports restored.
|
|
801
|
+
setTimeout(() => void stop().catch((error) => log(`recovery shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
802
|
+
return { t: "recovery", ok: true, committed: true, recovery: { ...recoveryView(), phase: "prepared" } };
|
|
803
|
+
}
|
|
804
|
+
if (msg.op === "release") {
|
|
805
|
+
if (recoveryPhase === "released") return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
|
|
806
|
+
if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
|
|
807
|
+
await bus.fenceRecovery();
|
|
808
|
+
const current = recoveryPeers();
|
|
809
|
+
const missing = (recoveryPeerSnapshot ?? []).filter((saved) => saved.state !== "offline" && !manualPaused.has(saved.id) && !budget.record(saved.id) && (!current[saved.id] || current[saved.id]!.state === "offline"));
|
|
810
|
+
if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
|
|
811
|
+
if (!recoveryReady()) return recoveryError("required peers or approvals are not ready");
|
|
812
|
+
if (!restored?.integrity || JSON.stringify(restored.integrity) !== JSON.stringify(integrity())) {
|
|
813
|
+
return recoveryError("queue, pause, task board or budget integrity changed during recovery");
|
|
814
|
+
}
|
|
815
|
+
archiveRestartSnapshot(opts.stateDir, op);
|
|
816
|
+
recoveryPhase = "released";
|
|
817
|
+
clearTimeout(recoveryLeaseTimer);
|
|
818
|
+
bus.setRecoveryHold(false);
|
|
819
|
+
budget.setRecoveryHold(false);
|
|
820
|
+
writeStatus();
|
|
821
|
+
return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
|
|
822
|
+
}
|
|
823
|
+
return recoveryError("unknown recovery operation");
|
|
824
|
+
}
|
|
825
|
+
|
|
614
826
|
function onMessage(sock: Sock, msg: any): void {
|
|
615
827
|
const c = sock.data;
|
|
616
828
|
const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
|
|
@@ -635,6 +847,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
635
847
|
} else {
|
|
636
848
|
c.peer = String(msg.peer ?? "claude");
|
|
637
849
|
if (!PEER_ID.test(c.peer) || RESERVED_IDS.has(c.peer)) return sock.close(4403, "peer id is reserved or malformed");
|
|
850
|
+
if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
|
|
638
851
|
let peer = bus.peers.get(c.peer);
|
|
639
852
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
640
853
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
@@ -648,7 +861,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
648
861
|
}
|
|
649
862
|
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
650
863
|
switch (msg.t) {
|
|
864
|
+
case "recovery":
|
|
865
|
+
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
866
|
+
void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
|
|
867
|
+
return;
|
|
651
868
|
case "send": {
|
|
869
|
+
if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
|
|
652
870
|
// A human at the console should not wait out the batch window; agents default to status.
|
|
653
871
|
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
654
872
|
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
@@ -681,12 +899,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
681
899
|
return void reply(uiSnapshot(msg.after ?? 0));
|
|
682
900
|
case "ui_action":
|
|
683
901
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
902
|
+
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
684
903
|
if (msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before acting" });
|
|
685
904
|
if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
|
|
686
905
|
void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
|
|
687
906
|
return;
|
|
688
907
|
case "start":
|
|
689
908
|
if (c.role !== "console") return;
|
|
909
|
+
if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
|
|
910
|
+
if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
|
|
690
911
|
startPeer(String(msg.peer), msg.args ?? {})
|
|
691
912
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
692
913
|
.then((r) => {
|
|
@@ -695,6 +916,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
695
916
|
});
|
|
696
917
|
return;
|
|
697
918
|
case "task":
|
|
919
|
+
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
698
920
|
taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
|
|
699
921
|
(text) => reply({ t: "task", ok: true, text }),
|
|
700
922
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
@@ -703,6 +925,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
703
925
|
case "pause":
|
|
704
926
|
case "resume": {
|
|
705
927
|
if (c.role !== "console") return;
|
|
928
|
+
if (recoveryActive()) return void reply({ t: msg.t, ok: false, error: "recovery is holding mutations" });
|
|
706
929
|
return void reply({ t: msg.t, ...holdPeer(msg.t, String(msg.peer)) });
|
|
707
930
|
}
|
|
708
931
|
case "ask":
|
|
@@ -741,6 +964,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
741
964
|
return;
|
|
742
965
|
case "budget":
|
|
743
966
|
if (c.role !== "console") return;
|
|
967
|
+
if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
|
|
744
968
|
if (msg.resume) {
|
|
745
969
|
if (!budget.override(String(msg.resume))) return void reply({ t: "budget", ok: false, error: `${msg.resume} is not paused by the budget coordinator` });
|
|
746
970
|
} else if (msg.set) {
|