@staix/agent-hub 0.12.19 → 0.12.21
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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { isAbsolute, join, resolve } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { processSignature } from "../pi/process-signature.ts";
|
|
5
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
6
|
+
import { atomicPrivateJSON } from "../hub/recovery-store.ts";
|
|
6
7
|
|
|
7
8
|
/** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
|
|
8
9
|
export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
|
|
@@ -63,7 +64,9 @@ export interface RecoveryBlocker {
|
|
|
63
64
|
| "ambiguous-terminal"
|
|
64
65
|
| "terminal-unready"
|
|
65
66
|
| "command-error"
|
|
66
|
-
| "ambiguous-create"
|
|
67
|
+
| "ambiguous-create"
|
|
68
|
+
| "launcher-exited"
|
|
69
|
+
| "terminal-gone";
|
|
67
70
|
message: string;
|
|
68
71
|
peer?: TerminalPeer;
|
|
69
72
|
handle?: string;
|
|
@@ -127,6 +130,12 @@ export interface TerminalRecoveryOptions {
|
|
|
127
130
|
instanceId?: string;
|
|
128
131
|
/** Injectable process-start identity used to reject stale or reused launcher PIDs. */
|
|
129
132
|
processIdentity?: ProcessIdentity;
|
|
133
|
+
/**
|
|
134
|
+
* #228: the identity as releases up to 0.12.12 computed it (unpinned `ps`, the reader's TZ and locale). It defaults
|
|
135
|
+
* to that read only when `processIdentity` is the default, so an injected identity is never second-guessed by a
|
|
136
|
+
* real `ps` read of the same pid.
|
|
137
|
+
*/
|
|
138
|
+
legacyIdentity?: ProcessIdentity;
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
export interface RecordedTerminalLaunch {
|
|
@@ -146,6 +155,8 @@ export interface RecordedTerminalLaunch {
|
|
|
146
155
|
|
|
147
156
|
const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
148
157
|
const MAX_COMMAND_MS = 30_000;
|
|
158
|
+
/** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
|
|
159
|
+
const EXIT_SLICE_MS = 5_000;
|
|
149
160
|
const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
|
|
150
161
|
type AllowedEnvName = (typeof ALLOWED_ENV)[number];
|
|
151
162
|
|
|
@@ -176,13 +187,27 @@ function errorText(result: CommandResult): string {
|
|
|
176
187
|
return stderr || `exit status ${commandStatus(result)}`;
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
/** Orca's structured error code, from the JSON it prints on stdout or stderr. */
|
|
191
|
+
function errorCode(result: CommandResult): unknown {
|
|
192
|
+
const fromStdout = object(object(decoded(result)).error).code;
|
|
193
|
+
if (fromStdout !== undefined) return fromStdout;
|
|
194
|
+
try { return object(object(JSON.parse(bytes(result.stderr))).error).code; } catch { return undefined; }
|
|
195
|
+
}
|
|
196
|
+
|
|
179
197
|
function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
|
|
180
198
|
return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
|
|
181
199
|
}
|
|
182
200
|
|
|
183
201
|
async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
|
|
184
202
|
const result = await runner(argv);
|
|
185
|
-
if (commandStatus(result) !== 0)
|
|
203
|
+
if (commandStatus(result) !== 0) {
|
|
204
|
+
// Orca 1.4.223 answers a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
|
|
205
|
+
if (errorCode(result) === "terminal_handle_stale") {
|
|
206
|
+
const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
|
|
207
|
+
throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
|
|
208
|
+
}
|
|
209
|
+
throw new OrcaCommandError(runnerFailure(argv, result));
|
|
210
|
+
}
|
|
186
211
|
const value = decoded(result);
|
|
187
212
|
const root = object(value);
|
|
188
213
|
const payload = responseResult(value);
|
|
@@ -214,6 +239,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
214
239
|
stateDir: config.stateDir ?? "",
|
|
215
240
|
instanceId: config.instanceId ?? "",
|
|
216
241
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
242
|
+
legacyIdentity: config.legacyIdentity ?? (config.processIdentity ? () => undefined : unpinnedProcessIdentity),
|
|
217
243
|
};
|
|
218
244
|
}
|
|
219
245
|
|
|
@@ -249,42 +275,97 @@ function defaultRunner(executable: string): CommandRunner {
|
|
|
249
275
|
|
|
250
276
|
const RECORD_FILE = "terminal-recovery.json";
|
|
251
277
|
|
|
252
|
-
function recordPath(stateDir: string): string {
|
|
278
|
+
export function recordPath(stateDir: string): string {
|
|
253
279
|
return join(stateDir, RECORD_FILE);
|
|
254
280
|
}
|
|
255
281
|
|
|
256
|
-
function
|
|
257
|
-
|
|
282
|
+
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
283
|
+
const row = object(item);
|
|
284
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none".
|
|
289
|
+
* #228: a row that fails validation is kept apart (`invalid`), never dropped: whatever it would answer is unknown, and
|
|
290
|
+
* `all` is the file as read, so a writer puts every such row back unchanged.
|
|
291
|
+
*/
|
|
292
|
+
type LaunchRecords = { rows: RecordedTerminalLaunch[]; invalid: unknown[]; all: unknown[] };
|
|
293
|
+
function launchRecords(stateDir: string): LaunchRecords | "unreadable" {
|
|
294
|
+
const none = { rows: [], invalid: [], all: [] };
|
|
295
|
+
if (!stateDir || !existsSync(recordPath(stateDir))) return none;
|
|
258
296
|
try {
|
|
259
297
|
const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
} catch {
|
|
265
|
-
return [];
|
|
298
|
+
if (!Array.isArray(value)) return "unreadable";
|
|
299
|
+
return { rows: value.filter(validRecord), invalid: value.filter((item) => !validRecord(item)), all: value };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? none : "unreadable";
|
|
266
302
|
}
|
|
267
303
|
}
|
|
268
304
|
|
|
269
|
-
|
|
305
|
+
/**
|
|
306
|
+
* #228: whether a row that cannot be evaluated may answer a question about this peer, project and instance: it does
|
|
307
|
+
* unless one of its fields is a readable string that names another one (a row for another peer or an older instance).
|
|
308
|
+
*/
|
|
309
|
+
function concerns(raw: unknown, query: { peer?: string; projectRoot: string; instanceId: string }): boolean {
|
|
310
|
+
const row = object(raw);
|
|
311
|
+
const other = (value: unknown, wanted?: string) => wanted !== undefined && typeof value === "string" && value !== wanted;
|
|
312
|
+
return !other(row.peer, query.peer) && !other(row.projectRoot, query.projectRoot) && !other(row.instanceId, query.instanceId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function writeLaunchRecords(stateDir: string, rows: unknown[]): void {
|
|
270
316
|
mkdirSync(stateDir, { recursive: true });
|
|
271
|
-
|
|
317
|
+
atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
|
|
272
318
|
}
|
|
273
319
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
320
|
+
/**
|
|
321
|
+
* #215: live when the recorded process signature matches; gone when the pid no longer exists (ESRCH) or another
|
|
322
|
+
* process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
|
|
323
|
+
*/
|
|
324
|
+
export type LauncherState = "live" | "gone" | "unknown";
|
|
325
|
+
async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<LauncherState> {
|
|
326
|
+
let current: string | undefined;
|
|
327
|
+
try { current = await identity(row.launcherPid) || undefined; } catch { /* unreadable: decided by the pid probe */ }
|
|
328
|
+
// #228: a launcher recorded by 0.12.12 or older hashed `ps` in its own TZ and locale (#177 pinned it in 0.12.13). Read
|
|
329
|
+
// the same unpinned way in this reader's environment, a match is that launcher; anything else stays gone (pid reuse).
|
|
330
|
+
if (current && current !== row.launcherSignature) {
|
|
331
|
+
let old: string | undefined;
|
|
332
|
+
try { old = await legacy(row.launcherPid) || undefined; } catch { /* no legacy reading: the pinned one decides */ }
|
|
333
|
+
if (old === row.launcherSignature) return "live";
|
|
280
334
|
}
|
|
335
|
+
return processLiveness(row.launcherPid, row.launcherSignature, () => current);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The launcher signature as releases up to 0.12.12 computed it: `ps` in the caller's own TZ and locale, unpinned. */
|
|
339
|
+
function unpinnedProcessIdentity(pid: number): string | undefined {
|
|
340
|
+
try {
|
|
341
|
+
const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
|
|
342
|
+
const text = result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
343
|
+
return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined;
|
|
344
|
+
} catch { return undefined; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* This instance's launch records whose launcher is live, those whose launcher cannot be identified (never gone), and
|
|
349
|
+
* the rows that cannot be evaluated and may be this project's and instance's (#228: unknown for their peer).
|
|
350
|
+
*/
|
|
351
|
+
async function recordsByState(records: LaunchRecords, projectRoot: string, instanceId: string, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[]; invalid: unknown[] }> {
|
|
352
|
+
if (!instanceId) return { live: [], unknown: [], invalid: [] };
|
|
353
|
+
const candidates = records.rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
|
|
354
|
+
const states = await Promise.all(candidates.map((item) => launcherState(item, identity, legacy)));
|
|
355
|
+
return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown"),
|
|
356
|
+
invalid: records.invalid.filter((raw) => concerns(raw, { projectRoot, instanceId })) };
|
|
281
357
|
}
|
|
282
358
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
359
|
+
/** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
|
|
360
|
+
export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState; invalidRow?: true } | undefined> {
|
|
361
|
+
const config = normalizeOptions(options);
|
|
362
|
+
if (!config.stateDir || !config.instanceId) return undefined;
|
|
363
|
+
const records = launchRecords(config.stateDir);
|
|
364
|
+
if (records === "unreadable") return { state: "unknown" };
|
|
365
|
+
// #228: a row that cannot be evaluated and may be this launcher's is unknown, never "no launcher".
|
|
366
|
+
if (records.invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) return { state: "unknown", invalidRow: true };
|
|
367
|
+
const record = records.rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
|
|
368
|
+
return record ? { record, state: await launcherState(record, config.processIdentity, config.legacyIdentity) } : undefined;
|
|
288
369
|
}
|
|
289
370
|
|
|
290
371
|
/**
|
|
@@ -311,7 +392,14 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
311
392
|
process.env.AGENTHUB_LAUNCH_ID = launchId;
|
|
312
393
|
process.env.AGENTHUB_INSTANCE_ID = instanceId;
|
|
313
394
|
const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
314
|
-
const
|
|
395
|
+
const existing = launchRecords(stateDir);
|
|
396
|
+
// #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
|
|
397
|
+
// then read those launchers as gone.
|
|
398
|
+
if (existing === "unreadable") {
|
|
399
|
+
throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
|
|
400
|
+
}
|
|
401
|
+
// #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
|
|
402
|
+
const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
|
|
315
403
|
rows.push(row);
|
|
316
404
|
writeLaunchRecords(stateDir, rows);
|
|
317
405
|
return row;
|
|
@@ -401,6 +489,8 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
|
|
|
401
489
|
code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
|
|
402
490
|
code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
|
|
403
491
|
code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
|
|
492
|
+
code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
|
|
493
|
+
code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
|
|
404
494
|
"inspect the named terminal and resume recovery after the identity is verified";
|
|
405
495
|
return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
|
|
406
496
|
}
|
|
@@ -429,12 +519,16 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
|
|
|
429
519
|
*/
|
|
430
520
|
export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string | SessionRef>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
|
|
431
521
|
const config = normalizeOptions(options);
|
|
432
|
-
const records = await liveRecords(config.stateDir, projectRoot, config.instanceId, config.processIdentity);
|
|
433
522
|
const blockers: RecoveryBlocker[] = [];
|
|
434
523
|
const bindings: TerminalBinding[] = [];
|
|
435
524
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
436
525
|
const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
|
|
437
526
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
527
|
+
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
528
|
+
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
529
|
+
const rows = launchRecords(config.stateDir);
|
|
530
|
+
if (rows === "unreadable") return { bindings, byPeer, blockers: [blocker("command-error", `${recordPath(config.stateDir)} cannot be read, so no terminal can be bound to its launch; inspect or move it aside, then plan or resume again`)], manualRequired: true };
|
|
531
|
+
const { live: records, unknown, invalid } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity, config.legacyIdentity);
|
|
438
532
|
|
|
439
533
|
let listed: Record<string, unknown>[];
|
|
440
534
|
try {
|
|
@@ -446,6 +540,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
446
540
|
|
|
447
541
|
for (const [peer, ref] of requested) {
|
|
448
542
|
const sessionId = ref.sessionId;
|
|
543
|
+
// #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
|
|
544
|
+
const unread = unknown.find((item) => item.peer === peer);
|
|
545
|
+
if (unread) {
|
|
546
|
+
blockers.push(blocker("ownership-unknown", `the ${peer} launcher recorded for terminal ${unread.handle} (pid ${unread.launcherPid}) cannot be identified, so no terminal can be bound to its launch; wait until it exits or end it, then plan or resume again`, peer, unread.handle));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) {
|
|
550
|
+
blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
449
553
|
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
|
|
450
554
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
451
555
|
for (const listedTerminal of candidates) {
|
|
@@ -500,16 +604,25 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
500
604
|
return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
|
|
501
605
|
}
|
|
502
606
|
|
|
607
|
+
/**
|
|
608
|
+
* One bounded Orca wait. Orca 1.4.223 answers a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
|
|
609
|
+
* that is "not satisfied", not a failure. Any other failure still throws.
|
|
610
|
+
*/
|
|
611
|
+
async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
|
|
612
|
+
const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
|
|
613
|
+
const result = await runner(argv);
|
|
614
|
+
if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
|
|
615
|
+
const root = responseResult(await run(async () => result, argv));
|
|
616
|
+
return root.satisfied === true || object(root.wait).satisfied === true;
|
|
617
|
+
}
|
|
618
|
+
|
|
503
619
|
/** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
|
|
504
620
|
export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, options?: CommandRunner | TerminalRecoveryOptions): Promise<IdleResult> {
|
|
505
621
|
const config = normalizeOptions(options);
|
|
506
622
|
const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
|
|
507
623
|
try {
|
|
508
624
|
await showBinding(binding, config);
|
|
509
|
-
const
|
|
510
|
-
const root = responseResult(value);
|
|
511
|
-
const wait = object(root.wait);
|
|
512
|
-
const satisfied = root.satisfied === true || wait.satisfied === true;
|
|
625
|
+
const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
|
|
513
626
|
const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
|
|
514
627
|
return { satisfied, manualRequired: !satisfied, blockers };
|
|
515
628
|
} catch (error) {
|
|
@@ -548,9 +661,13 @@ export async function closeTerminal(binding: TerminalBinding, timeoutMs = 600_00
|
|
|
548
661
|
}
|
|
549
662
|
}
|
|
550
663
|
|
|
551
|
-
/**
|
|
552
|
-
|
|
664
|
+
/**
|
|
665
|
+
* Create a replacement in the captured worktree, then prove TUI readiness and session mapping. `anySession` maps a
|
|
666
|
+
* fresh session the operator chose (#215): the terminal must still be this launch's, under any new session id.
|
|
667
|
+
*/
|
|
668
|
+
export async function createTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions, timeoutMs = 600_000, anySession = false): Promise<TerminalCreateResult> {
|
|
553
669
|
const config = normalizeOptions(options);
|
|
670
|
+
let made: string | undefined; // a later failure must still report the terminal it created
|
|
554
671
|
try {
|
|
555
672
|
const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
|
|
556
673
|
// The coordinator journals a pending creation before this call and reconciles
|
|
@@ -558,6 +675,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
558
675
|
const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
|
|
559
676
|
let created = terminalObject(value);
|
|
560
677
|
const createdHandle = nestedString(created, ["handle"]);
|
|
678
|
+
made = createdHandle;
|
|
561
679
|
if (!createdHandle) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", "Orca create returned no terminal handle", binding.peer)] };
|
|
562
680
|
const incarnationId = nestedString(created, ["incarnationId"]);
|
|
563
681
|
const worktreeId = nestedString(created, ["worktreeId"]) ?? binding.worktreeId;
|
|
@@ -566,20 +684,58 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
566
684
|
if (listed.length !== 1) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} lacks stable identity`, binding.peer, createdHandle)] };
|
|
567
685
|
created = listed[0]!;
|
|
568
686
|
}
|
|
569
|
-
|
|
570
|
-
|
|
687
|
+
let replacement: TerminalBinding = { ...binding, handle: createdHandle, incarnationId: nestedString(created, ["incarnationId"])!, worktreeId, launch: binding.launch, launchMetadata: binding.launch };
|
|
688
|
+
// The launch record `ahub <peer>` writes in the new terminal: live, gone (all recorded launchers), or neither.
|
|
689
|
+
const launches = async () => {
|
|
690
|
+
const rows = launchRecords(config.stateDir);
|
|
691
|
+
if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
|
|
692
|
+
// #228: a row that cannot be evaluated and may be this launch's is unknown too.
|
|
693
|
+
if (rows.invalid.some((raw) => concerns(raw, { peer: binding.peer, projectRoot: binding.projectRoot, instanceId: config.instanceId }))) return { live: undefined, gone: false, mayRun: true };
|
|
694
|
+
const possible = rows.rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
|
|
695
|
+
const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity, config.legacyIdentity) })));
|
|
696
|
+
return { live: states.find((entry) => entry.state === "live")?.item, gone: states.length > 0 && states.every((entry) => entry.state === "gone"), mayRun: states.some((entry) => entry.state !== "gone") };
|
|
697
|
+
};
|
|
698
|
+
// #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
|
|
699
|
+
// Readiness is awaited in slices, and between them the launcher's own record says whether it still runs: one that
|
|
700
|
+
// recorded itself and is gone failed. A stale handle is gone only when Orca no longer lists the terminal's
|
|
701
|
+
// incarnation and no recorded launcher may still run. One that never recorded itself is left to the readiness
|
|
702
|
+
// bound and to the coordinator's reconciliation of its pending receipt.
|
|
703
|
+
const exited = (why: string) => ({ created: true, ready: false, manualRequired: true, blockers: [blocker("launcher-exited", `the ${binding.peer} restoration launcher in terminal ${createdHandle} ${why} before its TUI was ready`, binding.peer, createdHandle)] });
|
|
704
|
+
const deadline = Date.now() + Math.min(timeoutMs, MAX_WAIT_MS);
|
|
705
|
+
const slice = () => Math.max(1, Math.min(EXIT_SLICE_MS, deadline - Date.now())); // Orca refuses --timeout-ms 0
|
|
706
|
+
let idle = await waitForIdle(replacement, slice(), config);
|
|
707
|
+
for (;;) {
|
|
708
|
+
if (idle.blockers[0]?.code === "terminal-gone") {
|
|
709
|
+
const launch = await launches();
|
|
710
|
+
if (launch.gone) return exited("exited");
|
|
711
|
+
if (launch.mayRun) break;
|
|
712
|
+
const inventory = await run(config.runner, ["terminal", "list", "--json"]);
|
|
713
|
+
// A truncated inventory cannot show that the terminal is no longer listed.
|
|
714
|
+
const listed = responseResult(inventory).truncated === true || listTerminals(inventory).some((terminal) => terminal.incarnationId === replacement.incarnationId);
|
|
715
|
+
if (!listed) return exited("lost its terminal");
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
if (idle.satisfied || idle.blockers[0]?.code !== "terminal-unready") break;
|
|
719
|
+
if ((await launches()).gone) return exited("exited");
|
|
720
|
+
if (Date.now() >= deadline) break;
|
|
721
|
+
idle = await waitForIdle(replacement, slice(), config);
|
|
722
|
+
}
|
|
571
723
|
if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
|
|
724
|
+
// An idle terminal may be the login shell the launcher returned to.
|
|
725
|
+
const launch = await launches();
|
|
726
|
+
if (launch.gone) return exited("exited");
|
|
572
727
|
const shown = await showBinding(replacement, config);
|
|
573
728
|
const identity = identityFrom(shown);
|
|
574
729
|
const session = sessionFrom(shown);
|
|
575
|
-
const
|
|
576
|
-
const
|
|
577
|
-
if ((identity !== binding.peer || session
|
|
730
|
+
const recorded = launch.live;
|
|
731
|
+
const same = (value: string | undefined) => anySession || value === binding.sessionId;
|
|
732
|
+
if ((identity !== binding.peer || !same(session)) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) {
|
|
578
733
|
return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
|
|
579
734
|
}
|
|
735
|
+
if (anySession && session) replacement = { ...replacement, sessionId: session };
|
|
580
736
|
return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
|
|
581
737
|
} catch (error) {
|
|
582
|
-
return { created:
|
|
738
|
+
return { created: made !== undefined, ready: false, manualRequired: true, blockers: [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, made ?? binding.handle)] };
|
|
583
739
|
}
|
|
584
740
|
}
|
|
585
741
|
|