@staix/agent-hub 0.21.0 → 0.22.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 +7 -0
- package/README.md +2 -2
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +2 -2
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +3 -1
- package/docs/operations.md +45 -0
- package/docs/profiles.md +1 -1
- package/docs/quickstart.md +5 -1
- package/docs/smoke.md +6 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verified.json +12 -12
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/identity.ts +3 -1
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +20 -2
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/smoke-pty-bridge.ts +67 -18
- package/src/cli/smoke-pty.ts +10 -2
- package/src/cli/upgrade-runtime.ts +16 -14
- package/src/cli/upgrade.ts +2 -1
- package/src/hub/bus.ts +18 -10
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/daemon.ts +61 -14
- package/src/hub/delivery-journal.ts +78 -57
- package/src/hub/learning.ts +33 -1
- package/src/hub/operational-settings.ts +79 -1
- package/src/hub/settings.ts +21 -1
- package/src/hub/tasks.ts +78 -1
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
5
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
6
6
|
"paths": [
|
|
7
7
|
"package.json",
|
|
8
8
|
"src/",
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"docs/security.md": {
|
|
14
|
-
"verifiedAgainst": "
|
|
14
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
21
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
22
22
|
"paths": [
|
|
23
23
|
"package.json",
|
|
24
24
|
"src/",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
37
|
"docs/quickstart.md": {
|
|
38
|
-
"verifiedAgainst": "
|
|
38
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
39
39
|
"paths": [
|
|
40
40
|
"package.json",
|
|
41
41
|
"src/",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"docs/agent-notes/adapters.md": {
|
|
46
|
-
"verifiedAgainst": "
|
|
46
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
47
47
|
"paths": [
|
|
48
48
|
"src/adapters/",
|
|
49
49
|
"src/pi/",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
]
|
|
94
94
|
},
|
|
95
95
|
"docs/agent-notes/budget.md": {
|
|
96
|
-
"verifiedAgainst": "
|
|
96
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
97
97
|
"paths": [
|
|
98
98
|
"src/hub/budget.ts",
|
|
99
99
|
"src/cli/statusline-tee.ts",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
]
|
|
118
118
|
},
|
|
119
119
|
"docs/agent-notes/bus.md": {
|
|
120
|
-
"verifiedAgainst": "
|
|
120
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
121
121
|
"paths": [
|
|
122
122
|
"src/hub/bus.ts",
|
|
123
123
|
"src/hub/envelope.ts",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
]
|
|
133
133
|
},
|
|
134
134
|
"docs/agent-notes/daemon.md": {
|
|
135
|
-
"verifiedAgainst": "
|
|
135
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
136
136
|
"paths": [
|
|
137
137
|
"src/hub/daemon.ts",
|
|
138
138
|
"src/hub/control-client.ts",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
]
|
|
226
226
|
},
|
|
227
227
|
"docs/agent-notes/tasks.md": {
|
|
228
|
-
"verifiedAgainst": "
|
|
228
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
229
229
|
"paths": [
|
|
230
230
|
"src/hub/tasks.ts",
|
|
231
231
|
"src/hub/board.ts",
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
]
|
|
265
265
|
},
|
|
266
266
|
"docs/agent-notes/tests.md": {
|
|
267
|
-
"verifiedAgainst": "
|
|
267
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
268
268
|
"paths": [
|
|
269
269
|
"test/",
|
|
270
270
|
"scripts/check.sh",
|
|
@@ -292,7 +292,7 @@
|
|
|
292
292
|
]
|
|
293
293
|
},
|
|
294
294
|
"docs/agent-notes/README.md": {
|
|
295
|
-
"verifiedAgainst": "
|
|
295
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
296
296
|
"paths": [
|
|
297
297
|
"src/cli/console-state.ts",
|
|
298
298
|
"src/cli/attention.ts",
|
|
@@ -301,7 +301,7 @@
|
|
|
301
301
|
]
|
|
302
302
|
},
|
|
303
303
|
"src/cli/help.ts": {
|
|
304
|
-
"verifiedAgainst": "
|
|
304
|
+
"verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
|
|
305
305
|
"paths": [
|
|
306
306
|
"src/cli/main.ts",
|
|
307
307
|
"src/cli/launch.ts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -22536,7 +22536,7 @@ init_security_baseline();
|
|
|
22536
22536
|
// package.json
|
|
22537
22537
|
var package_default = {
|
|
22538
22538
|
name: "@staix/agent-hub",
|
|
22539
|
-
version: "0.
|
|
22539
|
+
version: "0.22.0",
|
|
22540
22540
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
22541
22541
|
license: "MIT",
|
|
22542
22542
|
type: "module",
|
package/src/cli/help.ts
CHANGED
|
@@ -102,6 +102,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
102
102
|
["ahub source add <path|url> | list [--json] [--color=auto|always|never]", "list is person only, add the person's or the configured conductor's: register an input as a content-hashed source (src:<16 hex of the SHA-256>) under the hub state dir sources/<id>/ (meta.json, raw, text); a URL is fetched once by this command, https only, at most 5 redirects; up to 16 MiB, never truncated; a path under pii.paths or text matching signals.pii_patterns is private, and private is never downgraded"],
|
|
103
103
|
["ahub schedule list | run <id> | pause <id> | resume <id> [--json] [--color=auto|always|never]", "list is person only; run, pause and resume are the person's or the configured conductor's: the machine-local schedules (`schedules` in .agenthub/config.local.json); list shows each one's next run, run proposes one now through the same hold, open-task and PII path as the timer and restarts its period, pause stops the timer proposing it without counting a skip, resume starts a fresh period so a pause is never replayed as a run"],
|
|
104
104
|
["ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]", "person only, except approve (person or configured conductor): turn a plain request into a draft backlog card that nothing offers or pulls until you approve it; the configured planner is asked to split it with hub_backlog_split, or the hub model proposes the split (a PII request only on an on-prem model, never to a planner); each --source must be a registered src: id; approve clears the draft on it and its parts, discard cancels them through task cancel"],
|
|
105
|
+
["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
|
|
105
106
|
["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review; an agent sees only the tasks hub_task_show shows it"],
|
|
106
107
|
["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
|
|
107
108
|
["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -186,9 +186,11 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
|
|
|
186
186
|
if (command === "schedule") return ["list", "run", "pause", "resume"].includes(args.find(arg => arg !== "--json") ?? "") ? `schedule ${args.find(arg => arg !== "--json")}` : "schedule";
|
|
187
187
|
// #334: intake names its two subcommands in the audit and a refusal, never the request text (it can carry a secret).
|
|
188
188
|
if (command === "intake") return ["approve", "discard"].includes(args.find(arg => arg !== "--json") ?? "") ? `intake ${args.find(arg => arg !== "--json")}` : "intake";
|
|
189
|
+
// #514b: the audit and a refusal name the subcommand only, never the card id or any text.
|
|
190
|
+
if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
|
|
189
191
|
if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
|
|
190
192
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
|
191
|
-
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake"].includes(command)) return "unknown";
|
|
193
|
+
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn"].includes(command)) return "unknown";
|
|
192
194
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
|
|
193
195
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
194
196
|
if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "claim", "label", "list", "add", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Human output for `ahub learn` (#514b). Pure: parsed hub-tool JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
|
|
2
|
+
// Ids, counts, the key parts and the one line the hub itself generates are all it shows: the card's own text is never echoed (a card is data to the parser, not to this printer).
|
|
3
|
+
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
4
|
+
import type { Span } from "./console-state.ts";
|
|
5
|
+
|
|
6
|
+
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
7
|
+
const dash = (value: unknown) => value === undefined || value === null || value === "" ? "-" : String(value);
|
|
8
|
+
|
|
9
|
+
/** `ahub learn list [--all]`: one row per candidate, and what each command would do. */
|
|
10
|
+
export function renderLearnList(result: unknown, columns?: number, all = false): Span[][] {
|
|
11
|
+
const rows = list(result);
|
|
12
|
+
const open = rows.filter(row => row.status === "open").length;
|
|
13
|
+
const out: Span[][] = [...heading(all ? `Review checklist candidates: ${open} open, ${rows.length - open} decided` : `Review checklist candidates: ${open} open`, columns)];
|
|
14
|
+
if (!rows.length) {
|
|
15
|
+
out.push(blank, [s(all ? "no candidate has been filed" : "no open candidate; pass --all to include decided ones", "muted")]);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
out.push(...grid(["ID", "STATUS", "PROFILE/CLASS", "CRITERION", "TASKS", "LINE TO ADD"], rows.map((row: any) => [
|
|
19
|
+
s(`#${row.id}`, "taskRef"), s(dash(row.status), row.status === "open" ? "attention" : "muted"), s(`${dash(row.profile)}/${dash(row.class)}`), s(dash(row.criterion)), s(String(list(row.tasks).length)), s(dash(row.line)),
|
|
20
|
+
]), columns));
|
|
21
|
+
if (open) out.push(blank, [s("accept writes the line to .agenthub/config.local.json (ahub settings undo puts it back); reject closes the card. Nothing else changes.", "muted")]);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `ahub learn accept|reject <id>`: what was decided, and for accept exactly what was written and where. */
|
|
26
|
+
export function renderLearnChange(op: "accept" | "reject", result: unknown, columns?: number): Span[][] {
|
|
27
|
+
const r = (result ?? {}) as any;
|
|
28
|
+
const card = op === "accept" ? r.card : r;
|
|
29
|
+
const out: Span[][] = [...heading(op === "accept" ? `Candidate #${dash(card?.id)} accepted` : `Candidate #${dash(card?.id)} rejected and closed`, columns)];
|
|
30
|
+
if (op === "reject") return [...out, ...pair("written", "nothing", columns, 8, "muted")];
|
|
31
|
+
const a = r.accepted ?? {};
|
|
32
|
+
if (a.alreadyAccepted) return [...heading(`Candidate #${dash(card?.id)} was already accepted`, columns), ...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("written", "nothing: it was accepted before (ahub settings undo still puts back that write if it is the latest)", columns, 8, "muted")];
|
|
33
|
+
out.push(...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("line", a.line, columns, 8),
|
|
34
|
+
...pair("file", `${dash(a.file)} (this machine only; the shared config is untouched)`, columns, 8), ...pair("now", a.alreadyPresent ? `the checklist already had this line: nothing was written` : `${dash(a.lines)} lines in the checklist`, columns, 8),
|
|
35
|
+
...pair("applies", dash(a.applies), columns, 8, "attention"));
|
|
36
|
+
if (a.undo) out.push(blank, [s(`put it back with ${a.undo}`, "muted")]);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { fetchSourceUrl, MAX_SOURCE_BYTES, publicSourceView, screenRegistration,
|
|
|
26
26
|
import { renderSourceAdd, renderSourceList } from "./source-output.ts";
|
|
27
27
|
import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./schedule-output.ts";
|
|
28
28
|
import { renderIntake, renderIntakeChange } from "./intake-output.ts";
|
|
29
|
+
import { renderLearnChange, renderLearnList } from "./learn-output.ts";
|
|
29
30
|
import { terminalText } from "./console-state.ts";
|
|
30
31
|
import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
|
|
31
32
|
import { petView, readPet, renderPet } from "./pet.ts";
|
|
@@ -38,7 +39,7 @@ import { verifiedJcodeCommand } from "../adapters/jcode.ts";
|
|
|
38
39
|
import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
|
|
39
40
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
40
41
|
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
41
|
-
import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
|
|
42
|
+
import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow } from "../hub/settings.ts";
|
|
42
43
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
43
44
|
import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
|
|
44
45
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -1450,6 +1451,23 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1450
1451
|
return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
|
|
1451
1452
|
},
|
|
1452
1453
|
|
|
1454
|
+
learn: async () => {
|
|
1455
|
+
// #514b: the person's verdict on a recurring-criterion candidate (#514a). Person only, end to end: `learn` is a `console` command for an agent shell (classifyPeerCommand has no entry for it, so the
|
|
1456
|
+
// identity layer refuses it before this runs), and the daemon refuses any caller but the person again. `accept` writes ONE key (profile_overrides.review_checklist) to the machine-local
|
|
1457
|
+
// config through the settings writer; nothing in a card's text is written.
|
|
1458
|
+
const split = splitPresentation(args);
|
|
1459
|
+
const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
|
|
1460
|
+
const usage = "usage: ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]";
|
|
1461
|
+
const [sub, ...rest] = split.operands;
|
|
1462
|
+
if (sub === "list" && rest.every(arg => arg === "--all")) {
|
|
1463
|
+
const all = rest.includes("--all");
|
|
1464
|
+
return showToolText(data => renderLearnList(data, present.columns, all), await taskOp("learn_list", { ...(all ? { all: true } : {}) }), present);
|
|
1465
|
+
}
|
|
1466
|
+
const [id, ...extra] = rest;
|
|
1467
|
+
if ((sub !== "accept" && sub !== "reject") || !id || extra.length || !/^[1-9]\d*$/.test(id)) return fail(usage);
|
|
1468
|
+
return showToolText(data => renderLearnChange(sub, data, present.columns), await taskOp(sub === "accept" ? "learn_accept" : "learn_reject", { id: Number(id) }), present);
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1453
1471
|
intake: async () => {
|
|
1454
1472
|
// #334 (#511a): natural-language intake. Person only, except approve (#933: the configured conductor too, admitted and audited by the daemon).
|
|
1455
1473
|
// The request is a draft backlog card that nothing offers until `approve`; the hub, not this process, splits it.
|
|
@@ -1952,7 +1970,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1952
1970
|
return console.log(paint([{ text: result.text }], false));
|
|
1953
1971
|
}
|
|
1954
1972
|
if (sub === "undo") {
|
|
1955
|
-
const view = await request({ t: "settings_get" }), undoDef =
|
|
1973
|
+
const view = await request({ t: "settings_get" }), undoDef = undoableDef(view.undo, settingScope.instances, settingScope.catalog);
|
|
1956
1974
|
// A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
|
|
1957
1975
|
if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
|
|
1958
1976
|
const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { configRefusal } from "../hub/config-trust.ts";
|
|
2
|
+
import { readInstanceConfigLayers } from "../hub/instance-config.ts";
|
|
3
|
+
import { recoveryWaits, type RecoveryOperationalConfig, type RecoveryWaits } from "../hub/operational-settings.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #489 H4. The upgrade coordinator is a CLI process, not the daemon, so it reads the five recovery waits itself, from the project it is acting on, at the START of each wait (a wait already running keeps
|
|
7
|
+
* its deadline, and the receipt's `waits` records it). Both layers count, the machine-local file last, as `loadConfig` merges them; a layer git tracks as local policy is refused like every other local
|
|
8
|
+
* policy (`configRefusal`) and a file that cannot be read or parsed is the default. A recovery never fails on its configuration: bounds were checked when the value was written, and `recoveryWaits` falls
|
|
9
|
+
* back to the default for a value outside them.
|
|
10
|
+
*/
|
|
11
|
+
const LEAVES = new Set(["reconnect_wait_s", "unmanaged_reconnect_wait_s", "control_timeout_s", "shutdown_wait_s", "terminal_idle_wait_s"]);
|
|
12
|
+
export function readRecoveryWaits(projectRoot: string | undefined, read: (cwd: string) => ReturnType<typeof readInstanceConfigLayers> = readInstanceConfigLayers): RecoveryWaits {
|
|
13
|
+
if (!projectRoot) return recoveryWaits(() => undefined);
|
|
14
|
+
let merged: RecoveryOperationalConfig["recovery"];
|
|
15
|
+
try {
|
|
16
|
+
for (const layer of read(projectRoot)) {
|
|
17
|
+
const recovery = layer.raw.recovery;
|
|
18
|
+
if (!recovery || typeof recovery !== "object" || Array.isArray(recovery) || !Object.keys(recovery).some(key => LEAVES.has(key))) continue;
|
|
19
|
+
// The shared file is team policy and always read; the machine-local file only when git does not track it. Git is asked only when that file actually sets one of the five
|
|
20
|
+
// (a recovery polls and sends many control requests, and a file that sets none must cost no subprocess).
|
|
21
|
+
if (layer.name === "config.local.json" && configRefusal(projectRoot, layer.name)) continue;
|
|
22
|
+
merged = { ...merged, ...(recovery as Record<string, unknown>) };
|
|
23
|
+
}
|
|
24
|
+
} catch { return recoveryWaits(() => undefined); }
|
|
25
|
+
return recoveryWaits(() => ({ recovery: merged }));
|
|
26
|
+
}
|
|
@@ -1,33 +1,82 @@
|
|
|
1
1
|
// macOS script(1) requires a terminal on its own stdin, including when its child gets a PTY.
|
|
2
2
|
// Keep a real PTY in this owned bridge instead; the outer Node child remains tracked by stopOwnedProcess.
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #560: Bun up to 1.4.3 can hang for good inside `Bun.spawn({ terminal })` on macOS when the child writes and starts exiting before the call returns (oven-sh/bun#45300, fixed in 1.4.4): the
|
|
9
|
+
* event loop thread blocks in wait4 while the exiting session leader waits for that same thread to drain its pty. Measured here with a bare Bun script and no agent-hub code: 26 of 96 runs hung
|
|
10
|
+
* at 12 in parallel on 1.4.3 and 0 of 180 on 1.4.4. The child therefore starts behind a gate: a shell that waits for a file the bridge writes right after `Bun.spawn` has returned, then `exec`s the
|
|
11
|
+
* real command (same pid, same session, same terminal). The wait has no bound of its own: a bridge that dies before it opens the gate closes the pty, which hangs the shell up (the trap below), and a bridge
|
|
12
|
+
* that is alive but never opens it is a hung bridge, which `runPty` stops with its group.
|
|
13
|
+
* ponytail: ceiling and upgrade path. The gate exists only because of oven-sh/bun#45300 on Bun up to 1.4.3 (this repo pins 1.4.2 in `.bun-version`); the upgrade path is to delete GATE_SCRIPT, `gate` and `gateDir` once the pinned Bun is 1.4.4 or newer, which is its own task because it
|
|
14
|
+
* touches CI and every OS leg. The parent-death stop below stays whatever the Bun version: an orphaned bridge is this repository's bug, not Bun's. The gate costs about one shell start (a few ms).
|
|
15
|
+
*/
|
|
16
|
+
// The shell removes its own gate (file, then directory) once released and when it is hung up or terminated (a bridge killed with SIGKILL runs no cleanup, and the pty closing
|
|
17
|
+
// sends the shell SIGHUP), so no path leaves a directory behind. `exec` resets the traps for the real command.
|
|
18
|
+
const GATE_SCRIPT = 'd=${0%/*}; trap "rm -f \\"$0\\"; rmdir \\"$d\\" 2>/dev/null; exit 125" HUP TERM INT; while [ ! -e "$0" ]; do sleep 0.01; done; rm -f "$0"; rmdir "$d" 2>/dev/null; exec "$@"';
|
|
19
|
+
/** A bridge whose caller died is stopped instead of running on: its child holds a terminal nobody reads and, under launchd, nothing else would ever stop it. */
|
|
20
|
+
const PARENT_POLL_MS = 250;
|
|
21
|
+
const PARENT_TERM_GRACE_MS = 1_000;
|
|
22
|
+
const PARENT_HARD_EXIT_MS = 3_000;
|
|
23
|
+
|
|
24
|
+
// The caller is whoever started this bridge, read before anything that can take time: a caller that dies while `Bun.spawn` runs (the window #45300 widens) has already reparented this process by the
|
|
25
|
+
// time the call returns, and a parent read after it would be launchd and never change.
|
|
26
|
+
const parent = process.ppid;
|
|
27
|
+
const callerGone = (): boolean => process.ppid !== parent;
|
|
3
28
|
const argv = process.argv.slice(2);
|
|
4
29
|
if (!argv.length) {
|
|
5
30
|
process.exitCode = 125;
|
|
6
31
|
} else {
|
|
7
32
|
let streamClosed!: () => void;
|
|
8
33
|
const eof = new Promise<void>(resolve => { streamClosed = resolve; });
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
cols: 120, rows: 40, name: "xterm-256color",
|
|
13
|
-
data: (_terminal, data) => { process.stdout.write(data); },
|
|
14
|
-
exit: () => streamClosed(),
|
|
15
|
-
},
|
|
16
|
-
});
|
|
17
|
-
const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
|
|
18
|
-
process.on("SIGINT", stop); process.on("SIGTERM", stop);
|
|
34
|
+
const gateDir = process.platform === "darwin" ? mkdtempSync(join(tmpdir(), "ahub-pty-gate-")) : undefined;
|
|
35
|
+
const gate = gateDir ? join(gateDir, "open") : undefined;
|
|
36
|
+
let watch: ReturnType<typeof setInterval> | undefined;
|
|
19
37
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
const child = Bun.spawn(gate ? ["/bin/sh", "-c", GATE_SCRIPT, gate, ...argv] : argv, {
|
|
39
|
+
cwd: process.cwd(), env: process.env,
|
|
40
|
+
terminal: {
|
|
41
|
+
cols: 120, rows: 40, name: "xterm-256color",
|
|
42
|
+
data: (_terminal, data) => { process.stdout.write(data); },
|
|
43
|
+
exit: () => streamClosed(),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
|
|
47
|
+
process.on("SIGINT", stop); process.on("SIGTERM", stop);
|
|
48
|
+
/** The caller is gone: stop the child now, kill it after a grace period, and exit even if its exit is never reported. */
|
|
49
|
+
const abandon = () => {
|
|
50
|
+
clearInterval(watch);
|
|
51
|
+
stop();
|
|
52
|
+
setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, PARENT_TERM_GRACE_MS).unref();
|
|
53
|
+
setTimeout(() => process.exit(129), PARENT_HARD_EXIT_MS).unref();
|
|
54
|
+
};
|
|
55
|
+
// A caller that died while Bun.spawn ran never gets its command run: the gate stays shut and the gate shell takes the SIGTERM (its trap removes the gate and exits).
|
|
56
|
+
// Abandoning once is enough: the interval below exists only while the caller is still there, and clears itself the first time it is not.
|
|
57
|
+
if (callerGone()) abandon();
|
|
58
|
+
else {
|
|
59
|
+
if (gate) writeFileSync(gate, "");
|
|
60
|
+
watch = setInterval(() => { if (callerGone()) abandon(); }, PARENT_POLL_MS);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const code = await child.exited;
|
|
64
|
+
// Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
|
|
67
|
+
if (timer) clearTimeout(timer);
|
|
68
|
+
process.exitCode = code;
|
|
69
|
+
} catch {
|
|
70
|
+
process.exitCode = 125;
|
|
71
|
+
} finally {
|
|
72
|
+
child.terminal?.close();
|
|
73
|
+
process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
|
|
74
|
+
}
|
|
26
75
|
} catch {
|
|
27
76
|
process.exitCode = 125;
|
|
28
77
|
} finally {
|
|
29
|
-
|
|
30
|
-
|
|
78
|
+
if (watch) clearInterval(watch);
|
|
79
|
+
if (gateDir) rmSync(gateDir, { recursive: true, force: true });
|
|
31
80
|
}
|
|
32
81
|
}
|
|
33
82
|
export {};
|
package/src/cli/smoke-pty.ts
CHANGED
|
@@ -54,14 +54,22 @@ function waitForExit(proc: ChildProcess): Promise<{ code: number | null }> {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** Run an argv vector inside a fixed-size PTY, always stopping its owned process group before returning. */
|
|
57
|
-
export
|
|
57
|
+
export function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
|
|
58
|
+
return runPtyWith(commandFor, argv, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `runPty` with the launcher named by the caller. Only code can reach it (a test names a bridge that ignores SIGTERM or never exits); no config key, request or environment variable does, so the
|
|
63
|
+
* bridge a hub runs is always `commandFor`'s.
|
|
64
|
+
*/
|
|
65
|
+
export async function runPtyWith(launcher: (argv: readonly string[]) => string[], argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
|
|
58
66
|
validate(argv, options);
|
|
59
67
|
const started = Date.now();
|
|
60
68
|
if (options.signal?.aborted) return { outcome: "aborted", exitCode: null, outputBytes: 0, elapsedMs: Date.now() - started, cleaned: true };
|
|
61
69
|
|
|
62
70
|
let proc: ChildProcess;
|
|
63
71
|
try {
|
|
64
|
-
const command =
|
|
72
|
+
const command = launcher(argv);
|
|
65
73
|
proc = spawn(command[0]!, command.slice(1), {
|
|
66
74
|
cwd: options.cwd,
|
|
67
75
|
env: { ...options.env, TERM: "xterm-256color" },
|
|
@@ -3,6 +3,7 @@ import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
|
3
3
|
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
4
4
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
5
5
|
import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
|
|
6
|
+
import { readRecoveryWaits } from "./recovery-waits.ts";
|
|
6
7
|
import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
7
8
|
import { configRefusal } from "../hub/config-trust.ts";
|
|
8
9
|
import { validExternalInstanceBinding } from "../hub/external-instance.ts";
|
|
@@ -26,10 +27,9 @@ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type Rest
|
|
|
26
27
|
import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
|
|
27
28
|
import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const RECONNECT_WAIT_MS = 600_000;
|
|
30
|
+
// #489 H4: the reconnect, unmanaged reconnect, control request, terminal idle and shutdown waits are settings now (`recovery.*`, src/hub/operational-settings.ts), read by `readRecoveryWaits` at the start of
|
|
31
|
+
// each wait. Their defaults are the constants that stood here: an unmanaged Claude's plugin retries with a backoff of at most 30 s and three of those bound its reconnect wait (#206, 90 s); managed TUI startup
|
|
32
|
+
// can include resume and MCP handshakes, so its wait matches createTerminal's ten-minute readiness ceiling (600 s, which is also the setting's floor).
|
|
33
33
|
class AttachmentLauncherGone extends Error {
|
|
34
34
|
constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
|
|
35
35
|
}
|
|
@@ -80,12 +80,12 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
|
|
|
80
80
|
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
84
83
|
|
|
85
84
|
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
|
|
86
|
-
const
|
|
85
|
+
const controlTimeoutMs = readRecoveryWaits(project.root).controlTimeoutMs;
|
|
86
|
+
const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, controlTimeoutMs, protocol);
|
|
87
87
|
try {
|
|
88
|
-
const reply = await client.request(message,
|
|
88
|
+
const reply = await client.request(message, controlTimeoutMs);
|
|
89
89
|
if (reply.ok === false) {
|
|
90
90
|
// An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
|
|
91
91
|
if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
|
|
@@ -215,7 +215,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
215
215
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
216
216
|
// caller reads that as not inspected, never as stopped.
|
|
217
217
|
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
218
|
-
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null },
|
|
218
|
+
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, readRecoveryWaits(project.root).controlTimeoutMs, conductorPeer);
|
|
219
219
|
if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
|
|
220
220
|
const control = readControl(project.stateDir);
|
|
221
221
|
const sourceProtocol = control?.protocol;
|
|
@@ -469,7 +469,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
469
469
|
if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
|
|
470
470
|
throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: saved terminal identity changed or is not verified; manual-required`, { handle: saved.handle });
|
|
471
471
|
}
|
|
472
|
-
const idle = await waitForIdle(exact ? saved : current,
|
|
472
|
+
const idle = await waitForIdle(exact ? saved : current, readRecoveryWaits(planned.project.root).terminalIdleWaitMs, options);
|
|
473
473
|
if (!idle.satisfied) throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: terminal is not idle; manual-required`, { handle: saved.handle });
|
|
474
474
|
const observed = await inspectRecovery(planned.project);
|
|
475
475
|
if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
|
|
@@ -567,7 +567,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
567
567
|
if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
|
|
568
568
|
progress.terminals[key] = true; save(); return;
|
|
569
569
|
}
|
|
570
|
-
const idle = await waitForIdle(retired,
|
|
570
|
+
const idle = await waitForIdle(retired, readRecoveryWaits(retired.projectRoot).terminalIdleWaitMs, terminalOptions(run));
|
|
571
571
|
if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
|
|
572
572
|
progress.terminals[key] = "pending"; save();
|
|
573
573
|
await recoveryCurrent();
|
|
@@ -583,7 +583,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
583
583
|
};
|
|
584
584
|
const binding = waiting.binding;
|
|
585
585
|
const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
|
|
586
|
-
const
|
|
586
|
+
const reconnectWaitMs = readRecoveryWaits(planned.project.root).reconnectWaitMs; // read once, at the start: a wait already running keeps the deadline the receipt records
|
|
587
|
+
const startedAt = now(), deadline = startedAt + reconnectWaitMs;
|
|
587
588
|
const waitStep = op.step;
|
|
588
589
|
if (waitStep) {
|
|
589
590
|
(op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
|
|
@@ -656,7 +657,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
656
657
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
657
658
|
lastHubConnectAttempt: "unknown" };
|
|
658
659
|
save();
|
|
659
|
-
throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${
|
|
660
|
+
throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${reconnectWaitMs / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`, { handle: binding.handle, timeoutMs: reconnectWaitMs });
|
|
660
661
|
} catch (error) { waitFailed = true; throw error; }
|
|
661
662
|
finally {
|
|
662
663
|
const finish = () => {
|
|
@@ -1030,9 +1031,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1030
1031
|
}
|
|
1031
1032
|
if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
|
|
1032
1033
|
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
1033
|
-
const
|
|
1034
|
+
const unmanagedWaitMs = readRecoveryWaits(planned.project.root).unmanagedReconnectWaitMs;
|
|
1035
|
+
const deadline = now() + unmanagedWaitMs;
|
|
1034
1036
|
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
1035
|
-
if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${
|
|
1037
|
+
if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${unmanagedWaitMs / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`, { timeoutMs: unmanagedWaitMs });
|
|
1036
1038
|
await sleep(1000);
|
|
1037
1039
|
}
|
|
1038
1040
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
14
14
|
import { hubHome } from "../hub/project.ts";
|
|
15
|
+
import { readRecoveryWaits } from "./recovery-waits.ts";
|
|
15
16
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
16
17
|
import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
|
|
17
18
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
@@ -802,7 +803,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
802
803
|
throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
|
|
803
804
|
}
|
|
804
805
|
if (awaitShutdown) {
|
|
805
|
-
const deadline = driver.now() +
|
|
806
|
+
const deadline = driver.now() + readRecoveryWaits(project.root).shutdownWaitMs; // #489 H4: recovery.shutdown_wait_s, 30 s unset
|
|
806
807
|
// An already-sent commit can be between its final reply and the source closing its control server.
|
|
807
808
|
if (resumedShutdown) {
|
|
808
809
|
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|