@staix/agent-hub 0.20.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 +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -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/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Human output for `ahub source add|list` (#508). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
|
|
2
|
+
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
3
|
+
import { moment } 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 size = (bytes: unknown) => typeof bytes === "number" && Number.isFinite(bytes) ? bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB` : "unknown";
|
|
8
|
+
|
|
9
|
+
/** `ahub source add`: what was registered, and whether it was new or an upgrade of an existing record. */
|
|
10
|
+
export function renderSourceAdd(result: unknown, columns?: number): Span[][] {
|
|
11
|
+
const r = result as any, out: Span[][] = [...heading(r?.created ? `Registered ${r?.id}` : r?.upgraded ? `Already registered ${r?.id}; now private` : `Already registered ${r?.id}`, columns)];
|
|
12
|
+
out.push(...pair("kind", r?.kind ?? "unknown", columns, 8), ...pair("origin", r?.origin ?? "unknown", columns, 8), ...pair("sha256", r?.sha256 ?? "unknown", columns, 8), ...pair("size", size(r?.bytes), columns, 8));
|
|
13
|
+
out.push(...pair("private", r?.private ? "yes" : "no", columns, 8, r?.private ? "attention" : undefined), ...pair("text", r?.text ? "projected" : "none (no converter handles these bytes)", columns, 8, r?.text ? undefined : "muted"));
|
|
14
|
+
if (list(r?.reasons).length) out.push(...pair("because", list(r.reasons).join("; "), columns, 8, "attention"));
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `ahub source list`: one row per registered source, newest last. The person sees every origin of a private source. */
|
|
19
|
+
export function renderSourceList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
|
|
20
|
+
const sources = list(rows);
|
|
21
|
+
if (!sources.length) return [[s("no sources registered; add one with ahub source add <path|url>", "muted")]];
|
|
22
|
+
const out: Span[][] = [...heading(`Sources (${sources.length})`, columns)];
|
|
23
|
+
out.push(...grid(["ID", "KIND", "SIZE", "PRIVATE", "ADDED", "ORIGIN"], sources.map((row: any) => [
|
|
24
|
+
s(row.id, "issueRef"), s(row.kind), s(size(row.bytes)), s(row.private ? "yes" : "no", row.private ? "attention" : "muted"), s(moment(row.added_at, now)), s(row.origin),
|
|
25
|
+
]), columns));
|
|
26
|
+
const many = sources.filter((row: any) => list(row.origins).length > 1);
|
|
27
|
+
for (const row of many) out.push(blank, ...pair(row.id, `${list(row.origins).length} origins: ${list(row.origins).map((o: any) => `${o.origin}${o.private ? " (private)" : ""}`).join("; ")}`, columns, 20, undefined));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/src/cli/task-output.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// and detail, empty refs and plan): this module shows that stub and never asks for more.
|
|
6
6
|
import { type Span, type Tone, plural, table, terminalText, wrap } from "./console-state.ts";
|
|
7
7
|
import { moment } from "./sdlc-output.ts";
|
|
8
|
+
import { taskHolderText } from "./role-holders.ts";
|
|
8
9
|
|
|
9
10
|
type Line = Span[];
|
|
10
11
|
// Local copies of the layout primitives of sdlc-output.ts (#814 T1), the source of these helpers: that file stays untouched by this task
|
|
@@ -100,8 +101,8 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
|
|
|
100
101
|
const out: Line[] = [...para(`${id} ${dash(task?.title)}`, columns, undefined, "", 6).map((line, i): Line => i === 0 ? [s(id, "taskRef"), s(line[0]!.text.slice(id.length), "strong")] : line)];
|
|
101
102
|
const w = 10;
|
|
102
103
|
out.push(...pair("state", `${dash(task?.state)}${task?.cancelled ? " (cancelled)" : ""}`, columns, w, stateTone(task?.state), " "),
|
|
103
|
-
...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner",
|
|
104
|
-
if (task?.planner) out.push(...pair("planner", task
|
|
104
|
+
...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
|
|
105
|
+
if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
|
|
105
106
|
if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
|
|
106
107
|
out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
|
|
107
108
|
if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { authorizeConductorOperation, conductorRecoveryBinding } from "./conductor-recovery.ts";
|
|
1
2
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
2
3
|
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
3
4
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
4
|
-
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
|
|
6
|
+
import { readRecoveryWaits } from "./recovery-waits.ts";
|
|
7
|
+
import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
8
|
+
import { configRefusal } from "../hub/config-trust.ts";
|
|
9
|
+
import { validExternalInstanceBinding } from "../hub/external-instance.ts";
|
|
5
10
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
6
11
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
12
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
8
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
14
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
|
|
10
15
|
import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
|
|
@@ -22,10 +27,9 @@ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type Rest
|
|
|
22
27
|
import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
|
|
23
28
|
import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
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).
|
|
29
33
|
class AttachmentLauncherGone extends Error {
|
|
30
34
|
constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
|
|
31
35
|
}
|
|
@@ -76,12 +80,12 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
|
|
|
76
80
|
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
80
83
|
|
|
81
|
-
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
|
|
82
|
-
const
|
|
84
|
+
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
|
|
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);
|
|
83
87
|
try {
|
|
84
|
-
const reply = await client.request(message,
|
|
88
|
+
const reply = await client.request(message, controlTimeoutMs);
|
|
85
89
|
if (reply.ok === false) {
|
|
86
90
|
// An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
|
|
87
91
|
if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
|
|
@@ -142,10 +146,53 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/** No imports or executable catalog reconstruction: unknown kinds are only possible module instances. */
|
|
150
|
+
function recoveryDeclarations(projectRoot: string) {
|
|
151
|
+
const layers = readInstanceConfigLayers(projectRoot);
|
|
152
|
+
const modules = readAdapterModuleDeclarations(projectRoot, layers).modules;
|
|
153
|
+
const local = layers.find(layer => layer.name === "config.local.json");
|
|
154
|
+
const raw = local && !configRefusal(projectRoot, local.name) ? local.raw : {};
|
|
155
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
156
|
+
const instances = raw.instances === undefined ? {} : raw.instances;
|
|
157
|
+
if (!object(instances) || Object.keys(instances).length > 64) throw new Error("recovery instance declaration inventory unavailable");
|
|
158
|
+
const configured = object(raw.adapters) ? Object.keys(raw.adapters) : [];
|
|
159
|
+
const moduleInstances = Object.entries(instances).filter(([, entry]) => !object(entry) || typeof entry.kind !== "string" || !definitionOf(entry.kind) && !configured.includes(entry.kind));
|
|
160
|
+
return { modules, instances, configured, moduleInstances };
|
|
161
|
+
}
|
|
162
|
+
const hasRecoveryFeatures = (features: unknown, required: readonly string[]): features is string[] => Array.isArray(features) && features.every(feature => typeof feature === "string") && required.every(feature => features.includes(feature));
|
|
163
|
+
const EXTERNAL_INSTANCE_FEATURES = ["external_adapter_modules", "peer_instances", "external_adapter_instances"] as const;
|
|
164
|
+
|
|
165
|
+
/** Includes offline declarations and retained identities, so a reused target cannot downgrade their meaning. */
|
|
166
|
+
export function instanceRecoveryTargetRefusal(projectRoot: string, features: unknown, retainedPeers: readonly RecoveryPeer[] = []): string | undefined {
|
|
167
|
+
const declarations = recoveryDeclarations(projectRoot);
|
|
168
|
+
if ((declarations.moduleInstances.length || retainedPeers.some(peer => Object.hasOwn(peer, "externalInstance"))) && !hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) {
|
|
169
|
+
return "target lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; module instances require a supporting target before recovery effects";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
145
173
|
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
146
174
|
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
175
|
+
const inventory = recoveryDeclarations(projectRoot);
|
|
176
|
+
if (inventory.moduleInstances.length) {
|
|
177
|
+
if (!hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) return "source lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; restart with a supporting source before recovering module instances, including offline declarations";
|
|
178
|
+
const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? observedPeers as Record<string, unknown> : undefined;
|
|
179
|
+
for (const [id, declaration] of inventory.moduleInstances) {
|
|
180
|
+
const peer = rows?.[id] as RecoveryPeer | undefined;
|
|
181
|
+
const binding = peer?.externalInstance;
|
|
182
|
+
if (!declaration || typeof declaration !== "object" || !peer || !["idle", "busy", "paused", "offline"].includes(peer.state) || !validExternalInstanceBinding(binding, projectRoot, id) || binding.kind !== (declaration as { kind?: unknown }).kind ||
|
|
183
|
+
!inventory.modules.some(module => module.sha256 === binding.sha256 && relative(projectRoot, resolve(projectRoot, module.path)).split(sep).join("/") === binding.modulePath)) {
|
|
184
|
+
return "source external module instance binding unavailable or changed; a matching authenticated live startup catalog is required before recovery";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const configuredIds = [...inventory.configured, ...Object.entries(inventory.instances).filter(([, entry]) => entry && typeof entry === "object" && inventory.configured.includes((entry as { kind: string }).kind)).map(([id]) => id)];
|
|
188
|
+
if (configuredIds.length && !hasRecoveryFeatures(features, ["configured_acp"])) {
|
|
189
|
+
if (!rows || !Object.values(rows).every(peer => peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || Object.entries(rows).some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
|
|
190
|
+
}
|
|
191
|
+
// The authenticated source owns semantic admission; raw config and cached display metadata cannot reconstruct it.
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
147
194
|
const bound = readInstanceConfig(projectRoot);
|
|
148
|
-
if (bound.adapter_modules?.length && !(
|
|
195
|
+
if (bound.adapter_modules?.length && !hasRecoveryFeatures(features, ["external_adapter_modules"])) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
|
|
149
196
|
const configured = Object.keys(bound.adapters ?? {});
|
|
150
197
|
const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
|
|
151
198
|
if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
|
|
@@ -163,12 +210,12 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
|
|
|
163
210
|
return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
213
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup", conductorPeer?: string): Promise<Inspection> {
|
|
167
214
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
168
215
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
169
216
|
// caller reads that as not inspected, never as stopped.
|
|
170
217
|
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
171
|
-
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);
|
|
172
219
|
if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
|
|
173
220
|
const control = readControl(project.stateDir);
|
|
174
221
|
const sourceProtocol = control?.protocol;
|
|
@@ -203,7 +250,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
203
250
|
};
|
|
204
251
|
if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
|
|
205
252
|
try {
|
|
206
|
-
const readback = await rpc(project, { t: "status" }, sourceProtocol);
|
|
253
|
+
const readback = await rpc(project, { t: "status" }, sourceProtocol, conductorPeer);
|
|
207
254
|
status = readback.status;
|
|
208
255
|
} catch (error) {
|
|
209
256
|
if (isDatabaseBusy(error)) throw error;
|
|
@@ -218,7 +265,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
218
265
|
const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
|
|
219
266
|
if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
|
|
220
267
|
let response: any;
|
|
221
|
-
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
268
|
+
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL, conductorPeer); }
|
|
222
269
|
catch (error) {
|
|
223
270
|
if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
|
|
224
271
|
// A stop can complete between authenticated status and the second read. Never infer stopped
|
|
@@ -227,16 +274,21 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
227
274
|
peers: [], blockers: ["runtime changed during recovery inspection"] };
|
|
228
275
|
}
|
|
229
276
|
const peers = Object.values(response.recovery?.peers ?? {}) as any[];
|
|
277
|
+
if (peers.some(peer => peer && Object.hasOwn(peer, "externalInstance") && !validExternalInstanceBinding(peer.externalInstance, project.root, peer.id))) {
|
|
278
|
+
return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: ["source external module instance binding is invalid"] };
|
|
279
|
+
}
|
|
230
280
|
// #272: the hub's own words for what it waits on; shown to the person, never decided on.
|
|
231
281
|
const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
|
|
232
282
|
return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
233
283
|
recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
|
|
284
|
+
...(peer.observation === undefined ? {} : { observation: peer.observation }),
|
|
285
|
+
...(peer.externalInstance === undefined ? {} : { externalInstance: peer.externalInstance }),
|
|
234
286
|
...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
|
|
235
287
|
...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
|
|
236
288
|
...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
|
|
237
289
|
}
|
|
238
290
|
|
|
239
|
-
export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand): Promise<UpgradePlan> {
|
|
291
|
+
export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand, conductorPeer?: string): Promise<UpgradePlan> {
|
|
240
292
|
const projects = registeredProjects();
|
|
241
293
|
const sourceDigest = packageDigest(PACKAGE_ROOT);
|
|
242
294
|
const currentVersion = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
@@ -258,7 +310,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
258
310
|
for (const project of projects) {
|
|
259
311
|
if (kind === "restart" && project.root !== selectedRoot) continue;
|
|
260
312
|
let source: Inspection;
|
|
261
|
-
try { source = await inspectRecovery(project); }
|
|
313
|
+
try { source = await inspectRecovery(project, undefined, undefined, conductorPeer); }
|
|
262
314
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
263
315
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
264
316
|
const blockers = [...source.blockers];
|
|
@@ -288,6 +340,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
288
340
|
if (!launcher) {
|
|
289
341
|
unrecordedTerminals.push(peer.id);
|
|
290
342
|
delete peer.sessionId;
|
|
343
|
+
delete peer.args; // An unmanaged channel reconnects itself; its observed identity is not restoration data.
|
|
291
344
|
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
292
345
|
else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
|
|
293
346
|
continue;
|
|
@@ -364,7 +417,12 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
|
|
|
364
417
|
return dest;
|
|
365
418
|
}
|
|
366
419
|
|
|
367
|
-
|
|
420
|
+
const unboundRecoveryInspection = inspectRecovery;
|
|
421
|
+
|
|
422
|
+
export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">, conductorPeer?: string): RecoveryDriver {
|
|
423
|
+
let authorizedOperation: RecoveryOperation | undefined;
|
|
424
|
+
const recoveryCurrent = async () => { if (conductorPeer) { if (!authorizedOperation) throw new FinalRefusal("conductor recovery authority unavailable"); await authorizeConductorOperation(authorizedOperation, conductorPeer); } };
|
|
425
|
+
const inspectRecovery = (project: Project, features?: readonly string[], purpose?: "cleanup") => unboundRecoveryInspection(project, features, purpose, conductorPeer);
|
|
368
426
|
const now = clock?.now ?? (() => Date.now());
|
|
369
427
|
const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
370
428
|
/** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
|
|
@@ -376,6 +434,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
376
434
|
value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
|
|
377
435
|
value.AGENTHUB_UNATTENDED = "0";
|
|
378
436
|
if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
|
|
437
|
+
if (conductorPeer) value.AGENTHUB_PEER_ID = conductorPeer;
|
|
379
438
|
if (project) {
|
|
380
439
|
value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
|
|
381
440
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
@@ -390,14 +449,15 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
390
449
|
};
|
|
391
450
|
const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
|
|
392
451
|
const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
|
|
393
|
-
{ env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
452
|
+
{ env: { ...env(op, project, only), ...(conductorPeer ? { AGENTHUB_RECOVERY_CONDUCTOR: conductorPeer, AGENTHUB_RECOVERY_CONTINUATION: op.id } : {}) }, cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
394
453
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
395
454
|
return result;
|
|
396
455
|
};
|
|
397
|
-
const control = (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
|
|
456
|
+
const control = async (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
|
|
457
|
+
await recoveryCurrent();
|
|
398
458
|
const sourceProtocol = readControl(project.stateDir)?.protocol;
|
|
399
459
|
const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
|
|
400
|
-
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, protocol).then(() => {});
|
|
460
|
+
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }), ...(conductorPeer && authorizedOperation ? { authority: conductorRecoveryBinding(authorizedOperation, project, conductorPeer) } : {}) }, protocol, conductorPeer).then(() => {});
|
|
401
461
|
};
|
|
402
462
|
const nativeFailure = (planned: PlannedProject, peer: string, reason: RecoveryNativeCause["reason"], message: string,
|
|
403
463
|
details: Omit<RecoveryNativeCause, "kind" | "reason" | "projectId" | "peer"> = {}) =>
|
|
@@ -409,7 +469,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
409
469
|
if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
|
|
410
470
|
throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: saved terminal identity changed or is not verified; manual-required`, { handle: saved.handle });
|
|
411
471
|
}
|
|
412
|
-
const idle = await waitForIdle(exact ? saved : current,
|
|
472
|
+
const idle = await waitForIdle(exact ? saved : current, readRecoveryWaits(planned.project.root).terminalIdleWaitMs, options);
|
|
413
473
|
if (!idle.satisfied) throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: terminal is not idle; manual-required`, { handle: saved.handle });
|
|
414
474
|
const observed = await inspectRecovery(planned.project);
|
|
415
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 });
|
|
@@ -507,9 +567,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
507
567
|
if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
|
|
508
568
|
progress.terminals[key] = true; save(); return;
|
|
509
569
|
}
|
|
510
|
-
const idle = await waitForIdle(retired,
|
|
570
|
+
const idle = await waitForIdle(retired, readRecoveryWaits(retired.projectRoot).terminalIdleWaitMs, terminalOptions(run));
|
|
511
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`);
|
|
512
572
|
progress.terminals[key] = "pending"; save();
|
|
573
|
+
await recoveryCurrent();
|
|
513
574
|
const result = await closeTerminal(retired, 0, terminalOptions(run));
|
|
514
575
|
if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
|
|
515
576
|
progress.terminals[key] = true; save();
|
|
@@ -522,7 +583,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
522
583
|
};
|
|
523
584
|
const binding = waiting.binding;
|
|
524
585
|
const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
|
|
525
|
-
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;
|
|
526
588
|
const waitStep = op.step;
|
|
527
589
|
if (waitStep) {
|
|
528
590
|
(op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
|
|
@@ -566,6 +628,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
566
628
|
const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
|
|
567
629
|
session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
|
|
568
630
|
if (decision.action === "settle" && session) {
|
|
631
|
+
await recoveryCurrent();
|
|
569
632
|
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
|
|
570
633
|
return session;
|
|
571
634
|
}
|
|
@@ -594,7 +657,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
594
657
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
595
658
|
lastHubConnectAttempt: "unknown" };
|
|
596
659
|
save();
|
|
597
|
-
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 });
|
|
598
661
|
} catch (error) { waitFailed = true; throw error; }
|
|
599
662
|
finally {
|
|
600
663
|
const finish = () => {
|
|
@@ -609,6 +672,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
609
672
|
};
|
|
610
673
|
return {
|
|
611
674
|
...(liveTestPeer === undefined ? {} : { liveTestPeer }),
|
|
675
|
+
...(conductorPeer ? { conductorPeer, authorize: async (op: RecoveryOperation) => { await authorizeConductorOperation(op, conductorPeer); authorizedOperation = op; } } : {}),
|
|
612
676
|
now, sleep, inspect: inspectRecovery,
|
|
613
677
|
recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
|
|
614
678
|
stage: async (op) => {
|
|
@@ -627,6 +691,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
627
691
|
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
628
692
|
}
|
|
629
693
|
if (cached && op.targetFeatures !== undefined) {
|
|
694
|
+
for (const planned of op.plan.projects) {
|
|
695
|
+
const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, op.targetFeatures, planned.source.peers) : undefined;
|
|
696
|
+
if (refusal) throw new FinalRefusal(refusal);
|
|
697
|
+
}
|
|
630
698
|
if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
631
699
|
if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
632
700
|
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
@@ -640,6 +708,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
640
708
|
if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
641
709
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
642
710
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
711
|
+
for (const planned of op.plan.projects) {
|
|
712
|
+
const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, targetFeatures, planned.source.peers) : undefined;
|
|
713
|
+
if (refusal) throw new FinalRefusal(refusal);
|
|
714
|
+
}
|
|
643
715
|
if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
644
716
|
if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
645
717
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
@@ -705,6 +777,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
705
777
|
// the original peer roster and verified target features, including after a resume.
|
|
706
778
|
await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
|
|
707
779
|
progress.terminals[key] = "pending"; save();
|
|
780
|
+
await recoveryCurrent();
|
|
708
781
|
const result = await closeTerminal(binding, 0, terminalOptions(run));
|
|
709
782
|
if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
|
|
710
783
|
progress.terminals[key] = true; save();
|
|
@@ -716,6 +789,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
716
789
|
throw new Error("committed restart snapshot is missing or belongs to another operation");
|
|
717
790
|
}
|
|
718
791
|
const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
|
|
792
|
+
await recoveryCurrent();
|
|
719
793
|
if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
|
|
720
794
|
await command(op, ["up"], p);
|
|
721
795
|
},
|
|
@@ -727,6 +801,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
727
801
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
728
802
|
if (current && current.state !== "offline") continue;
|
|
729
803
|
const adapter = projectDefinition(peer.id, planned.project.root)!;
|
|
804
|
+
if (conductorPeer) {
|
|
805
|
+
await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, PROTOCOL, conductorPeer);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
730
808
|
const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
|
|
731
809
|
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
732
810
|
if (refusal) throw new Error(refusal);
|
|
@@ -803,6 +881,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
803
881
|
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
804
882
|
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
805
883
|
// the operator or the plan accepted, counts as restored.
|
|
884
|
+
await recoveryCurrent();
|
|
806
885
|
if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
|
|
807
886
|
throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
|
|
808
887
|
}
|
|
@@ -817,6 +896,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
817
896
|
// Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
|
|
818
897
|
// a launch writes the waiver first, but this must not rely on that.
|
|
819
898
|
// The planned session needs no waiver: the target's snapshot already expects it.
|
|
899
|
+
await recoveryCurrent();
|
|
820
900
|
if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
821
901
|
if (repair) repair.state = "attempted"; // a verified spontaneous reattachment also closes this repair allowance
|
|
822
902
|
// The person accepted one loss, not a permanent exemption for later repairs of the replacement.
|
|
@@ -881,6 +961,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
881
961
|
if (fresh()) {
|
|
882
962
|
if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
|
|
883
963
|
// The restored daemon must accept the new session instead of the recorded one.
|
|
964
|
+
await recoveryCurrent();
|
|
884
965
|
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
885
966
|
}
|
|
886
967
|
if (repair) {
|
|
@@ -894,6 +975,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
894
975
|
command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
|
|
895
976
|
const binding = { ...original, launch, launchMetadata: launch };
|
|
896
977
|
progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
|
|
978
|
+
await recoveryCurrent();
|
|
897
979
|
const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
|
|
898
980
|
const exited = restored.blockers.find((b) => b.code === "launcher-exited");
|
|
899
981
|
if (exited) {
|
|
@@ -949,9 +1031,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
949
1031
|
}
|
|
950
1032
|
if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
|
|
951
1033
|
// #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
|
|
952
|
-
const
|
|
1034
|
+
const unmanagedWaitMs = readRecoveryWaits(planned.project.root).unmanagedReconnectWaitMs;
|
|
1035
|
+
const deadline = now() + unmanagedWaitMs;
|
|
953
1036
|
while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
|
|
954
|
-
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 });
|
|
955
1038
|
await sleep(1000);
|
|
956
1039
|
}
|
|
957
1040
|
}
|
|
@@ -970,6 +1053,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
970
1053
|
if (!configured.size) await command(op, ["setup", "--yes"]);
|
|
971
1054
|
},
|
|
972
1055
|
installGlobal: async (op) => {
|
|
1056
|
+
await recoveryCurrent();
|
|
973
1057
|
const result = await run([process.execPath, "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts"], { env: env(op), timeoutMs: 180_000 });
|
|
974
1058
|
if (result.code !== 0) throw new Error("global installation failed; project runtimes use the retained staged package");
|
|
975
1059
|
const readback = await run(["ahub", "--version"], { env: env(op) });
|
|
@@ -1005,7 +1089,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1005
1089
|
} catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
|
|
1006
1090
|
}
|
|
1007
1091
|
},
|
|
1008
|
-
refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
1092
|
+
refreshManager: async (op) => { if (conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
1009
1093
|
verify: async (planned, progress, op, save) => {
|
|
1010
1094
|
const live = await inspectRecovery(planned.project);
|
|
1011
1095
|
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");
|