@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/CHANGELOG.md +22 -0
- package/README.md +58 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +148 -20
- package/src/adapters/acp.ts +15 -3
- package/src/adapters/claude-channel.ts +9 -3
- package/src/adapters/codex-appserver.ts +47 -9
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +188 -68
- 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 +55 -0
- package/src/hub/control-client.ts +50 -9
- package/src/hub/daemon.ts +326 -45
- package/src/hub/lifecycle.ts +189 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
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";
|
|
@@ -26,7 +26,9 @@ import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
|
26
26
|
import { newEnvelope, parseMarker, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
27
27
|
import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
|
|
28
28
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
29
|
+
import { VERSION } from "../version.ts";
|
|
29
30
|
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
31
|
+
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
30
32
|
|
|
31
33
|
export interface HubConfig {
|
|
32
34
|
watchdog_ms: number;
|
|
@@ -82,6 +84,8 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
export interface DaemonOptions {
|
|
87
|
+
projectId?: string;
|
|
88
|
+
instanceId?: string;
|
|
85
89
|
cwd: string;
|
|
86
90
|
stateDir: string;
|
|
87
91
|
controlPort: number;
|
|
@@ -131,9 +135,18 @@ class WsPeer extends BasePeer {
|
|
|
131
135
|
async stop(): Promise<void> {
|
|
132
136
|
this.sock?.close();
|
|
133
137
|
}
|
|
138
|
+
|
|
139
|
+
recoveryMetadata(): Record<string, unknown> {
|
|
140
|
+
return { launch: { kind: "claude-channel", peer: this.id } };
|
|
141
|
+
}
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
export async function startDaemon(opts: DaemonOptions) {
|
|
145
|
+
const projectId = opts.projectId ?? randomUUID();
|
|
146
|
+
const instanceId = opts.instanceId ?? randomUUID();
|
|
147
|
+
const startupCleanup: (() => void)[] = [];
|
|
148
|
+
let ready = false;
|
|
149
|
+
try {
|
|
137
150
|
const config = opts.config ?? loadConfig(opts.cwd);
|
|
138
151
|
mkdirSync(opts.stateDir, { recursive: true });
|
|
139
152
|
const logFile = join(opts.stateDir, "hub.log");
|
|
@@ -143,9 +156,73 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
143
156
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
144
157
|
const token = randomBytes(24).toString("hex");
|
|
145
158
|
|
|
159
|
+
const server = Bun.serve<Client>({
|
|
160
|
+
hostname: "127.0.0.1",
|
|
161
|
+
port: opts.controlPort,
|
|
162
|
+
fetch(req, srv) {
|
|
163
|
+
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
164
|
+
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
165
|
+
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
166
|
+
},
|
|
167
|
+
websocket: {
|
|
168
|
+
message(sock, data) {
|
|
169
|
+
try {
|
|
170
|
+
onMessage(sock, JSON.parse(String(data)));
|
|
171
|
+
} catch (e) {
|
|
172
|
+
log(`bad control message: ${(e as Error).message}`);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
close(sock) {
|
|
176
|
+
consoles.delete(sock);
|
|
177
|
+
sock.data.tail?.();
|
|
178
|
+
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
179
|
+
if (peer instanceof WsPeer) peer.detach(sock);
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
startupCleanup.push(() => server.stop(true));
|
|
184
|
+
|
|
185
|
+
|
|
146
186
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
147
187
|
let inference: Inference | undefined;
|
|
148
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
|
+
};
|
|
149
226
|
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
150
227
|
const chain = projectChain(opts.cwd);
|
|
151
228
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
@@ -180,6 +257,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
180
257
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "notice", line }));
|
|
181
258
|
};
|
|
182
259
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
260
|
+
startupCleanup.push(() => board.close());
|
|
183
261
|
const tasks = new Tasks({
|
|
184
262
|
board,
|
|
185
263
|
bus,
|
|
@@ -191,7 +269,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
191
269
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
192
270
|
});
|
|
193
271
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
194
|
-
const manualPaused = new Set<PeerId>(); // `ahub pause`: the coordinator never lifts these
|
|
195
272
|
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
196
273
|
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
197
274
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
@@ -234,7 +311,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
234
311
|
},
|
|
235
312
|
notify: (line) => notify(line),
|
|
236
313
|
});
|
|
314
|
+
budget.setRecoveryHold(recoveryActive());
|
|
237
315
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
316
|
+
startupCleanup.push(() => budget.close());
|
|
238
317
|
let kimiSessionTotal = 0;
|
|
239
318
|
/** `total` is the session's running count: only what was added since the last update goes into the rolling window. */
|
|
240
319
|
const onKimiTokens = (total: number) => {
|
|
@@ -267,6 +346,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
267
346
|
}, 5_000),
|
|
268
347
|
];
|
|
269
348
|
for (const i of intervals) i.unref?.();
|
|
349
|
+
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
270
350
|
|
|
271
351
|
/** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
|
|
272
352
|
const redact = (e: BusEvent): BusEvent => {
|
|
@@ -277,7 +357,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
277
357
|
return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
|
|
278
358
|
};
|
|
279
359
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
280
|
-
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir });
|
|
360
|
+
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
281
361
|
|
|
282
362
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
283
363
|
async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
@@ -325,6 +405,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
325
405
|
}
|
|
326
406
|
throw new Error(`unknown task operation ${op}`);
|
|
327
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
|
+
}
|
|
328
418
|
const permissions = new Map<string, { push: string; done: (optionId: string | undefined) => void }>();
|
|
329
419
|
let stopping = false;
|
|
330
420
|
|
|
@@ -332,7 +422,51 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
332
422
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
333
423
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
334
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
|
+
};
|
|
335
464
|
const status = () => ({
|
|
465
|
+
projectId,
|
|
466
|
+
instanceId,
|
|
467
|
+
version: VERSION,
|
|
468
|
+
protocol: PROTOCOL,
|
|
469
|
+
stopping,
|
|
336
470
|
pid: process.pid,
|
|
337
471
|
cwd: opts.cwd,
|
|
338
472
|
controlPort: server.port,
|
|
@@ -343,19 +477,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
343
477
|
),
|
|
344
478
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
345
479
|
tasks: board.counts(),
|
|
480
|
+
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
346
481
|
});
|
|
347
482
|
const writeStatus = () => {
|
|
348
483
|
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
349
|
-
writeFileSync(`${file}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
350
|
-
renameSync(`${file}.tmp`, file);
|
|
484
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
485
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
351
486
|
};
|
|
352
487
|
|
|
353
488
|
bus.tap((e) => {
|
|
354
489
|
e = redact(e);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (uiEvents.length > 200) uiEvents.shift();
|
|
358
|
-
}
|
|
490
|
+
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
491
|
+
if (uiEvents.length > 200) uiEvents.shift();
|
|
359
492
|
if (e.t === "state") log(`state ${e.peer} -> ${e.state}`);
|
|
360
493
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
361
494
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
@@ -364,6 +497,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
364
497
|
});
|
|
365
498
|
|
|
366
499
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
500
|
+
if (stopping) return undefined;
|
|
367
501
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
368
502
|
const id = randomUUID().slice(0, 8);
|
|
369
503
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
@@ -388,6 +522,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
388
522
|
// One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
|
|
389
523
|
const starting = new Map<string, Promise<Record<string, unknown>>>();
|
|
390
524
|
function startPeer(peer: string, args: { model?: string; route?: string }): Promise<Record<string, unknown>> {
|
|
525
|
+
if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
|
|
391
526
|
const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
|
|
392
527
|
starting.set(peer, running);
|
|
393
528
|
return running;
|
|
@@ -404,6 +539,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
404
539
|
const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_cmd;
|
|
405
540
|
const kimi = new AcpPeer("kimi", {
|
|
406
541
|
cmd,
|
|
542
|
+
...(args.model ? { launchModel: args.model } : {}),
|
|
407
543
|
cwd: opts.cwd,
|
|
408
544
|
watchdogMs: config.watchdog_ms,
|
|
409
545
|
onPermission,
|
|
@@ -412,6 +548,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
412
548
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
413
549
|
preamble: roleContract("kimi", config.roles),
|
|
414
550
|
});
|
|
551
|
+
recoveryTaskPreface("kimi");
|
|
415
552
|
await ensurePreface("kimi");
|
|
416
553
|
bus.add(kimi);
|
|
417
554
|
await kimi.start();
|
|
@@ -437,6 +574,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
437
574
|
watchdogMs: config.watchdog_ms,
|
|
438
575
|
log,
|
|
439
576
|
});
|
|
577
|
+
recoveryTaskPreface("codex");
|
|
440
578
|
await ensurePreface("codex");
|
|
441
579
|
bus.add(codex);
|
|
442
580
|
await codex.start();
|
|
@@ -450,7 +588,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
450
588
|
// The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
|
|
451
589
|
const routingKey = JSON.stringify([routing.targets, routing.routes]);
|
|
452
590
|
if (sidecar && routingKey !== sidecarRouting) {
|
|
453
|
-
sidecar.stop();
|
|
591
|
+
await sidecar.stop();
|
|
454
592
|
sidecar = undefined;
|
|
455
593
|
}
|
|
456
594
|
if (route && opts.switchyardPort) {
|
|
@@ -483,6 +621,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
483
621
|
maxSteps: config.local.max_steps,
|
|
484
622
|
log,
|
|
485
623
|
});
|
|
624
|
+
recoveryTaskPreface("local");
|
|
486
625
|
await ensurePreface("local");
|
|
487
626
|
bus.add(local);
|
|
488
627
|
await local.start();
|
|
@@ -507,6 +646,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
507
646
|
function uiSnapshot(after: number) {
|
|
508
647
|
return {
|
|
509
648
|
ok: true,
|
|
649
|
+
projectId,
|
|
650
|
+
instanceId,
|
|
510
651
|
status: { peers: status().peers },
|
|
511
652
|
tasks: board.list().map((task) => {
|
|
512
653
|
const view = tasks.publicView(task);
|
|
@@ -566,9 +707,125 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
566
707
|
}
|
|
567
708
|
}
|
|
568
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
|
+
|
|
569
826
|
function onMessage(sock: Sock, msg: any): void {
|
|
570
827
|
const c = sock.data;
|
|
571
|
-
const reply = (body: Record<string, unknown>) => sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
828
|
+
const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
|
|
572
829
|
if (!c.authed) {
|
|
573
830
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
574
831
|
if (msg.v !== PROTOCOL) {
|
|
@@ -576,8 +833,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
576
833
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
577
834
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
578
835
|
}
|
|
836
|
+
if ((msg.projectId && msg.projectId !== projectId) || (msg.instanceId && msg.instanceId !== instanceId) ||
|
|
837
|
+
(msg.projectRoot && msg.projectRoot !== opts.cwd)) return sock.close(4404, "project or instance mismatch");
|
|
838
|
+
if (!["peer", "tools", "console"].includes(msg.role)) return sock.close(4403, "invalid client role");
|
|
839
|
+
if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
|
|
579
840
|
c.authed = true;
|
|
580
|
-
c.role = msg.role
|
|
841
|
+
c.role = msg.role;
|
|
581
842
|
if (c.role === "console") consoles.add(sock);
|
|
582
843
|
else if (c.role === "tools") {
|
|
583
844
|
// Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
|
|
@@ -586,6 +847,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
586
847
|
} else {
|
|
587
848
|
c.peer = String(msg.peer ?? "claude");
|
|
588
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");
|
|
589
851
|
let peer = bus.peers.get(c.peer);
|
|
590
852
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
591
853
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
@@ -595,10 +857,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
595
857
|
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
596
858
|
});
|
|
597
859
|
}
|
|
598
|
-
return void reply({ t: "welcome" });
|
|
860
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
|
|
599
861
|
}
|
|
862
|
+
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
600
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;
|
|
601
868
|
case "send": {
|
|
869
|
+
if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
|
|
602
870
|
// A human at the console should not wait out the batch window; agents default to status.
|
|
603
871
|
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
604
872
|
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
@@ -625,8 +893,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
625
893
|
}
|
|
626
894
|
case "status":
|
|
627
895
|
return void reply({ t: "status", status: status() });
|
|
896
|
+
case "ui_snapshot":
|
|
897
|
+
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
898
|
+
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
899
|
+
return void reply(uiSnapshot(msg.after ?? 0));
|
|
900
|
+
case "ui_action":
|
|
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" });
|
|
903
|
+
if (msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before acting" });
|
|
904
|
+
if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
|
|
905
|
+
void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
|
|
906
|
+
return;
|
|
628
907
|
case "start":
|
|
629
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" });
|
|
630
911
|
startPeer(String(msg.peer), msg.args ?? {})
|
|
631
912
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
632
913
|
.then((r) => {
|
|
@@ -635,6 +916,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
635
916
|
});
|
|
636
917
|
return;
|
|
637
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" });
|
|
638
920
|
taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
|
|
639
921
|
(text) => reply({ t: "task", ok: true, text }),
|
|
640
922
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
@@ -643,6 +925,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
643
925
|
case "pause":
|
|
644
926
|
case "resume": {
|
|
645
927
|
if (c.role !== "console") return;
|
|
928
|
+
if (recoveryActive()) return void reply({ t: msg.t, ok: false, error: "recovery is holding mutations" });
|
|
646
929
|
return void reply({ t: msg.t, ...holdPeer(msg.t, String(msg.peer)) });
|
|
647
930
|
}
|
|
648
931
|
case "ask":
|
|
@@ -681,6 +964,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
681
964
|
return;
|
|
682
965
|
case "budget":
|
|
683
966
|
if (c.role !== "console") return;
|
|
967
|
+
if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
|
|
684
968
|
if (msg.resume) {
|
|
685
969
|
if (!budget.override(String(msg.resume))) return void reply({ t: "budget", ok: false, error: `${msg.resume} is not paused by the budget coordinator` });
|
|
686
970
|
} else if (msg.set) {
|
|
@@ -694,7 +978,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
694
978
|
if (c.role === "console") permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined);
|
|
695
979
|
return;
|
|
696
980
|
case "kill":
|
|
697
|
-
if (c.role
|
|
981
|
+
if (c.role !== "console") return void reply({ ok: false, error: "kill is a console command" });
|
|
982
|
+
if (msg.instanceId !== undefined && msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before stopping" });
|
|
983
|
+
reply({ t: "stopping", ok: true, instanceId });
|
|
984
|
+
// Let the acknowledgement flush before closing the control listener.
|
|
985
|
+
setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
698
986
|
return;
|
|
699
987
|
default:
|
|
700
988
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
@@ -702,45 +990,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
702
990
|
}
|
|
703
991
|
}
|
|
704
992
|
|
|
705
|
-
const server = Bun.serve<Client>({
|
|
706
|
-
hostname: "127.0.0.1",
|
|
707
|
-
port: opts.controlPort,
|
|
708
|
-
fetch(req, srv) {
|
|
709
|
-
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
710
|
-
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
711
|
-
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
712
|
-
},
|
|
713
|
-
websocket: {
|
|
714
|
-
message(sock, data) {
|
|
715
|
-
try {
|
|
716
|
-
onMessage(sock, JSON.parse(String(data)));
|
|
717
|
-
} catch (e) {
|
|
718
|
-
log(`bad control message: ${(e as Error).message}`);
|
|
719
|
-
}
|
|
720
|
-
},
|
|
721
|
-
close(sock) {
|
|
722
|
-
consoles.delete(sock);
|
|
723
|
-
sock.data.tail?.();
|
|
724
|
-
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
725
|
-
if (peer instanceof WsPeer) peer.detach(sock);
|
|
726
|
-
},
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
993
|
|
|
730
|
-
|
|
731
|
-
|
|
994
|
+
let shutdown: Promise<void> | undefined;
|
|
995
|
+
function stop(): Promise<void> {
|
|
996
|
+
if (shutdown) return shutdown;
|
|
997
|
+
shutdown = stopOnce().catch((error) => { shutdown = undefined; throw error; });
|
|
998
|
+
return shutdown;
|
|
999
|
+
}
|
|
1000
|
+
async function stopOnce(): Promise<void> {
|
|
732
1001
|
stopping = true;
|
|
733
1002
|
log("hub stopping");
|
|
1003
|
+
writeStatus();
|
|
734
1004
|
dashboard?.stop();
|
|
735
|
-
for (const p of permissions.values()) p.done(undefined);
|
|
736
|
-
await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
|
|
737
|
-
sidecar?.stop();
|
|
738
1005
|
for (const i of intervals) clearInterval(i);
|
|
739
1006
|
for (const done of checkpointWaits.values()) done(undefined);
|
|
1007
|
+
for (const p of permissions.values()) p.done(undefined);
|
|
1008
|
+
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
1009
|
+
// Drain those starts before taking the final owned-process snapshot.
|
|
1010
|
+
await Promise.allSettled([...starting.values()]);
|
|
1011
|
+
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
|
|
1012
|
+
const failed = exits.find((r) => r.status === "rejected");
|
|
1013
|
+
if (failed?.status === "rejected") throw failed.reason;
|
|
740
1014
|
budget.close();
|
|
741
1015
|
board.close();
|
|
742
1016
|
server.stop(true);
|
|
743
|
-
|
|
1017
|
+
try {
|
|
1018
|
+
const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
|
|
1019
|
+
if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|
|
1020
|
+
} catch { /* another owner or no published state: never remove it */ }
|
|
744
1021
|
onStop?.();
|
|
745
1022
|
}
|
|
746
1023
|
let onStop: (() => void) | undefined;
|
|
@@ -752,5 +1029,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
752
1029
|
writeFileSync(join(opts.stateDir, "hub.pid"), `${process.pid}\n`);
|
|
753
1030
|
writeStatus();
|
|
754
1031
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
1032
|
+
ready = true;
|
|
755
1033
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
1036
|
+
}
|
|
756
1037
|
}
|