@awebai/oats 0.30.3 → 0.32.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/bin/oats.mjs +216 -79
- package/docs/configuration.md +68 -8
- package/docs/desktop-cli-api.md +233 -27
- package/docs/desktop-instance-start.md +3 -1
- package/docs/desktop.md +36 -0
- package/docs/execution-targets.md +53 -36
- package/docs/implementation.md +59 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +10 -1
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +2 -2
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.31.0.md +130 -0
- package/docs/release-notes/v0.32.0.md +137 -0
- package/docs/schedules.md +4 -1
- package/docs/servers.md +94 -25
- package/docs/souls-and-instances.md +2 -2
- package/lib/attachments.mjs +2 -1
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +241 -238
- package/lib/errors.mjs +17 -0
- package/lib/instance-inspect.mjs +13 -4
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/remote.mjs +792 -36
- package/lib/resolve.mjs +23 -7
- package/lib/schedule.mjs +21 -8
- package/lib/servers.mjs +272 -45
- package/lib/session-input.mjs +9 -23
- package/lib/session-viewer.mjs +0 -5
- package/lib/triggers.mjs +10 -8
- package/lib/workspace.mjs +163 -56
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/lib/herdr.mjs +0 -143
package/lib/core.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
35
|
import { accessSync, constants as fsConstants } from "node:fs";
|
|
36
|
+
import { recordLocalInput } from "./local-inputs.mjs";
|
|
36
37
|
import { createHash, randomUUID } from "node:crypto";
|
|
37
38
|
import { fileURLToPath } from "node:url";
|
|
38
39
|
import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
|
|
@@ -41,9 +42,8 @@ import { attachSessionTarget } from "./session-viewer.mjs";
|
|
|
41
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
42
43
|
import { appendEvent } from "./instance-events.mjs";
|
|
43
44
|
import { killGroup } from "./process-group.mjs";
|
|
44
|
-
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
45
45
|
|
|
46
|
-
import { oatsError } from "./errors.mjs";
|
|
46
|
+
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
47
47
|
import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
|
|
48
48
|
import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
|
|
49
49
|
async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
|
|
@@ -126,7 +126,22 @@ export function legacyCapturedHomes(root) {
|
|
|
126
126
|
return { code: "legacy-captured-home", instances: homes.map((h) => h.instance), homes: homes.map((h) => h.home),
|
|
127
127
|
message: `${homes.length} captured home${one ? "" : "s"} (0.24–0.25; the captured/portable path was removed in 0.26) ${one ? "has" : "have"} no 0.26 runtime: start, inspect and in-home commands refuse ${one ? "it" : "them"}; retire ${one ? "it" : "them"} (\`oats retire\` still works) and re-spawn from the deployment (${homes.map((h) => h.instance).join(", ")})` };
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
// The tmux session a new tmux instance opens its window in, when oats-local.yaml session.tmuxSession
|
|
130
|
+
// names none (0.31). PI_AGENTS_TMUX_SESSION is the pre-0.31 variable, still honoured. The Desktop keeps
|
|
131
|
+
// the same default in packages/desktop/server/tmux-status.mjs (it does not import lib/): change both.
|
|
132
|
+
export const DEFAULT_TMUX_SESSION = process.env.OATS_TMUX_SESSION || process.env.PI_AGENTS_TMUX_SESSION || "oats-agents";
|
|
133
|
+
/**
|
|
134
|
+
* The tmux session a NEW tmux launch opens its window in (0.31): the caller's, else the deployment's
|
|
135
|
+
* oats-local.yaml `session.tmuxSession`, else OATS_TMUX_SESSION, else PI_AGENTS_TMUX_SESSION, else
|
|
136
|
+
* oats-agents, read from `env` at call time. A launched home keeps the session it recorded: session
|
|
137
|
+
* start/restart never read this.
|
|
138
|
+
*/
|
|
139
|
+
export function sessionDefaults(dir, { tmuxSession } = {}, env = process.env) {
|
|
140
|
+
let local = null;
|
|
141
|
+
try { local = loadLocal(dir); } catch (e) { if (e?.code !== "E_LOCAL_MISSING") throw e; }
|
|
142
|
+
const declared = local?.local?.session ?? {};
|
|
143
|
+
return { tmuxSession: tmuxSession || declared.tmuxSession || env.OATS_TMUX_SESSION || env.PI_AGENTS_TMUX_SESSION || "oats-agents" };
|
|
144
|
+
}
|
|
130
145
|
/** Package root (this file lives in <pkg>/lib/). */
|
|
131
146
|
export const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
132
147
|
export const OATS_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
@@ -169,7 +184,13 @@ export function slug(s) {
|
|
|
169
184
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
170
185
|
return r || "agent";
|
|
171
186
|
}
|
|
172
|
-
|
|
187
|
+
/** `command -v <bin>`, once per (bin, PATH) in this process: the answer only changes with PATH. */
|
|
188
|
+
const whichMemo = new Map();
|
|
189
|
+
function which(bin) {
|
|
190
|
+
const key = `${bin}\0${process.env.PATH ?? ""}`;
|
|
191
|
+
if (!whichMemo.has(key)) whichMemo.set(key, shTry(`command -v ${shq(bin)}`));
|
|
192
|
+
return whichMemo.get(key);
|
|
193
|
+
}
|
|
173
194
|
|
|
174
195
|
// ---------- yaml-ish ----------
|
|
175
196
|
/** `__proto__` is never data in a plain-object mapping: assigning it REWRITES
|
|
@@ -511,7 +532,7 @@ export const LAYERS = ["knowledge", "messaging", "tasks"];
|
|
|
511
532
|
// deployment's oats-local.yaml (lead decision 2: a spawn-time HOST choice,
|
|
512
533
|
// never a soul field). Selected at spawn or session start/restart by name.
|
|
513
534
|
export const LAUNCH_HARNESSES = ["pi", "claude", "codex"];
|
|
514
|
-
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo"]);
|
|
535
|
+
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo", "default"]);
|
|
515
536
|
/** A launch configuration's harness: `harness`, or `runtime`, its pre-0.27 name (0.26.0
|
|
516
537
|
* deployments wrote it) — read either (lead call 6). */
|
|
517
538
|
export const launchConfigHarness = (entry) => (Object.hasOwn(entry, "harness") ? entry.harness : entry.runtime);
|
|
@@ -527,7 +548,7 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
527
548
|
if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
|
|
528
549
|
if (name === "none") bad("cannot be named none: that word selects no configuration");
|
|
529
550
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
|
|
530
|
-
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo)`);
|
|
551
|
+
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo, default)`);
|
|
531
552
|
if (Object.hasOwn(entry, "harness") && Object.hasOwn(entry, "runtime") && entry.harness !== entry.runtime) bad(`names harness ${JSON.stringify(entry.harness)} and runtime ${JSON.stringify(entry.runtime)}: \`runtime\` is the pre-0.27 name of \`harness\` — keep one`);
|
|
532
553
|
if (!LAUNCH_HARNESSES.includes(launchConfigHarness(entry))) bad(`needs harness: one of ${LAUNCH_HARNESSES.join(", ")}`);
|
|
533
554
|
const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
|
|
@@ -548,12 +569,25 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
548
569
|
}
|
|
549
570
|
if (entry.model !== undefined) text(entry.model, "model");
|
|
550
571
|
if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
|
|
572
|
+
if (entry.default !== undefined && typeof entry.default !== "boolean") bad("default must be true or false");
|
|
551
573
|
return entry;
|
|
552
574
|
}
|
|
575
|
+
/** At most one `default: true` per harness (0.32, feature launch-config-default): the host's baseline for
|
|
576
|
+
* that harness has one source. `map` is name → entry; entries are validated first. */
|
|
577
|
+
export function validateLaunchConfigDefaults(map, where) {
|
|
578
|
+
const byHarness = new Map();
|
|
579
|
+
for (const [name, entry] of Object.entries(map || {})) {
|
|
580
|
+
if (entry?.default !== true) continue;
|
|
581
|
+
const harness = launchConfigHarness(entry);
|
|
582
|
+
if (byHarness.has(harness)) throw Object.assign(oatsError("E_LAUNCH_CONFIG_INVALID", `launch configurations ${JSON.stringify(byHarness.get(harness))} and ${JSON.stringify(name)}${where ? ` in ${where}` : ""} are both default: true for ${harness}; keep one default per harness`), { details: { harness, configurations: [byHarness.get(harness), name] } });
|
|
583
|
+
byHarness.set(harness, name);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
553
586
|
function validateLaunchConfigs(map, file) {
|
|
554
587
|
if (map === undefined) return;
|
|
555
588
|
if (!map || typeof map !== "object" || Array.isArray(map)) throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch-configs in ${file} must be a map of name to configuration`);
|
|
556
589
|
for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
|
|
590
|
+
validateLaunchConfigDefaults(map, file);
|
|
557
591
|
}
|
|
558
592
|
/** The launch configurations effective at `dir`: the `launch-configs:` of the
|
|
559
593
|
* oats-local.yaml found walking up from it (the deployment), validated. None
|
|
@@ -571,7 +605,7 @@ export function launchConfigsAt(dir) {
|
|
|
571
605
|
const source = dirname(found.path);
|
|
572
606
|
for (const [name, entry] of Object.entries(map || {})) {
|
|
573
607
|
if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`launch-configs.${name}.runtime in ${found.path}`);
|
|
574
|
-
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), source, shadows: [] };
|
|
608
|
+
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), ...(entry.default === true ? { default: true } : {}), source, shadows: [] };
|
|
575
609
|
}
|
|
576
610
|
return out;
|
|
577
611
|
}
|
|
@@ -844,9 +878,14 @@ export function officialPackageCatalog() {
|
|
|
844
878
|
function readCatalogFile() {
|
|
845
879
|
const file = officialCatalogFile();
|
|
846
880
|
const empty = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
847
|
-
|
|
881
|
+
// An OATS_PACKAGE_CATALOG override is local configuration (observation.localRevision); the bundled
|
|
882
|
+
// catalog belongs to the kernel install.
|
|
883
|
+
const override = !!process.env.OATS_PACKAGE_CATALOG;
|
|
884
|
+
if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
|
|
848
885
|
let doc;
|
|
849
|
-
|
|
886
|
+
const text = readFileSync(file, "utf8");
|
|
887
|
+
if (override) recordLocalInput(file, text);
|
|
888
|
+
try { doc = JSON.parse(text); }
|
|
850
889
|
catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
|
|
851
890
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
|
|
852
891
|
const out = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
@@ -1233,7 +1272,7 @@ export const HARNESS_PACKAGE_MANAGERS = {
|
|
|
1233
1272
|
* different marketplaces into one identity. */
|
|
1234
1273
|
identity: (spec) => String(spec || "").trim(),
|
|
1235
1274
|
safeSpec: (spec) => typeof spec === "string" && /^[a-z0-9][\w.-]*@[a-z0-9][\w.-]*$/i.test(spec),
|
|
1236
|
-
/** The executable is
|
|
1275
|
+
/** The executable is SELECTED by the launch (a configuration may name a wrapper
|
|
1237
1276
|
* such as `claude-personal`). Probing and installing through the literal
|
|
1238
1277
|
* `claude` would inspect a DIFFERENT account's plugins than the session
|
|
1239
1278
|
* actually launches with — passing preflight while the real harness lacks
|
|
@@ -1834,20 +1873,21 @@ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
|
|
|
1834
1873
|
* Spawn an instance of `agent` (as returned by findAgent/listAgents).
|
|
1835
1874
|
* o: { instance?, purpose?, name?, repo?, work?, harness?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
|
|
1836
1875
|
*/
|
|
1837
|
-
/**
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
|
|
1841
|
-
export function resolveClaudeBinary(contextDir) {
|
|
1876
|
+
/** `oats-claude-config` (a one-line file naming the claude binary, found walking up from the context) is no
|
|
1877
|
+
* longer read (0.32): a host names its claude executable in a default launch configuration. A file still in
|
|
1878
|
+
* reach of a new claude launch refuses it, naming the file and the configuration to declare instead. */
|
|
1879
|
+
export function legacyClaudeConfigRefusal(contextDir) {
|
|
1842
1880
|
let d = resolve(contextDir);
|
|
1843
1881
|
while (true) {
|
|
1844
1882
|
const f = join(d, "oats-claude-config");
|
|
1845
1883
|
if (existsSync(f)) {
|
|
1846
|
-
|
|
1847
|
-
|
|
1884
|
+
let name = null;
|
|
1885
|
+
try { name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")) || null; } catch {}
|
|
1886
|
+
const exe = name ? `executable: ${name}, ` : "";
|
|
1887
|
+
return Object.assign(oatsError("E_CLAUDE_CONFIG_REMOVED", `${f} is no longer read (OATS 0.32): declare this machine's claude default in oats-local.yaml instead — launch-configs: { <name>: { harness: claude, ${exe}default: true } } (oats launch-config set <name> --file <json>) — then delete ${f}`), { details: { file: f, executable: name, fix: `declare a claude default launch configuration (${exe}default: true), then delete ${f}` } });
|
|
1848
1888
|
}
|
|
1849
1889
|
const parent = dirname(d);
|
|
1850
|
-
if (parent === d) return
|
|
1890
|
+
if (parent === d) return null;
|
|
1851
1891
|
d = parent;
|
|
1852
1892
|
}
|
|
1853
1893
|
}
|
|
@@ -1947,11 +1987,10 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
|
|
|
1947
1987
|
const found = [];
|
|
1948
1988
|
const problems = [];
|
|
1949
1989
|
// The session launches with the SELECTED executable (a configuration's,
|
|
1950
|
-
//
|
|
1951
|
-
// `claude-personal`), so probing another binary would inspect a different
|
|
1990
|
+
// the host's harness default included), so probing another binary would inspect a different
|
|
1952
1991
|
// account's packages than the instance will actually use. The probe is the
|
|
1953
1992
|
// manager's controlled list subcommand; no launch argument is added to it.
|
|
1954
|
-
const probeOpts = { context: contextDir, ...(bin ? { bin } :
|
|
1993
|
+
const probeOpts = { context: contextDir, ...(bin ? { bin } : {}) };
|
|
1955
1994
|
for (const cap of resolved.capabilities || []) {
|
|
1956
1995
|
for (const raw of cap.manifest?.requires || []) {
|
|
1957
1996
|
if (!raw || typeof raw !== "object") continue;
|
|
@@ -2045,8 +2084,10 @@ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
|
|
|
2045
2084
|
|
|
2046
2085
|
/** The executable a launch uses: a configuration's declared one (a bare name
|
|
2047
2086
|
* on PATH; a path against the deployment directory when relative) or the
|
|
2048
|
-
* harness's
|
|
2087
|
+
* harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
|
|
2088
|
+
* of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
|
|
2049
2089
|
export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
|
|
2090
|
+
if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
|
|
2050
2091
|
if (declared) {
|
|
2051
2092
|
if (declared.includes("/")) {
|
|
2052
2093
|
const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
|
|
@@ -2055,10 +2096,8 @@ export function resolveLaunchExecutable({ harness, declared, declaringDir, conte
|
|
|
2055
2096
|
const found = which(declared);
|
|
2056
2097
|
return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
|
|
2057
2098
|
}
|
|
2058
|
-
const
|
|
2059
|
-
|
|
2060
|
-
const found = which(name);
|
|
2061
|
-
return { path: found || null, declared: null, resolvedFrom: harness === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
|
|
2099
|
+
const found = which(harness);
|
|
2100
|
+
return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
|
|
2062
2101
|
}
|
|
2063
2102
|
/** null when `path` is a regular executable file; otherwise why not. */
|
|
2064
2103
|
export function checkLaunchExecutable(path) {
|
|
@@ -2093,7 +2132,7 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2093
2132
|
// applies the current definition.
|
|
2094
2133
|
// Named or not: the recorded executable (a saved wrapper, say), args and
|
|
2095
2134
|
// env are what runs; later edits of the scope never change it.
|
|
2096
|
-
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, frozen: true };
|
|
2135
|
+
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, ...(frozen.launchConfigDefault === true ? { harnessDefault: true } : {}), frozen: true };
|
|
2097
2136
|
wanted = "none";
|
|
2098
2137
|
}
|
|
2099
2138
|
if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
|
|
@@ -2106,6 +2145,9 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2106
2145
|
// host default would: below the flags and a selected configuration.
|
|
2107
2146
|
const harness = config?.harness || selection.harness || (frozen ? frozen.harness : preference?.harness || agent?.harness || "pi");
|
|
2108
2147
|
if (!LAUNCH_HARNESSES.includes(harness)) bad("E_UNSUPPORTED_HARNESS", `unknown harness "${harness}" (pi|claude|codex)`);
|
|
2148
|
+
// 0.32 (feature launch-config-default): a launch that selected no configuration runs this host's default
|
|
2149
|
+
// for its harness, if one is declared. An explicit `--launch-config none` asks for the bare harness.
|
|
2150
|
+
const harnessDefault = !config && !(selection.launchConfig === "none" && !preference) ? harnessDefaultConfig(launchConfigs, harness) : null;
|
|
2109
2151
|
let model, modelSource;
|
|
2110
2152
|
// K6: an EXPLICIT "use the harness's native default" is distinct from an
|
|
2111
2153
|
// omitted model (which inherits the configuration's or soul's preference).
|
|
@@ -2131,8 +2173,19 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2131
2173
|
// is no proof it fits another one. Nothing is passed across.
|
|
2132
2174
|
model = ""; modelSource = "native default (harness differs from the soul's)";
|
|
2133
2175
|
} else { model = resolveModelPreference(agent?.model || "", harness); modelSource = model ? "soul default" : "native default"; }
|
|
2176
|
+
// The harness default's model is the last fallback: below every layer that names one, above the harness's own.
|
|
2177
|
+
if (harnessDefault?.model && !model && !nativeDefault) {
|
|
2178
|
+
model = resolveModelPreference(harnessDefault.model, harness); modelSource = `launch-config ${harnessDefault.name}`;
|
|
2179
|
+
if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${harnessDefault.name} (the ${harness} default) names model ${JSON.stringify(harnessDefault.model)}, which has no entry usable by harness ${harness}`);
|
|
2180
|
+
}
|
|
2181
|
+
if (harnessDefault) config = harnessDefault;
|
|
2134
2182
|
return { config, harness, model, modelSource, configuredYolo: config?.yolo };
|
|
2135
2183
|
}
|
|
2184
|
+
/** This host's default launch configuration for `harness` (`default: true`), marked `harnessDefault`, or null. */
|
|
2185
|
+
export function harnessDefaultConfig(launchConfigs, harness) {
|
|
2186
|
+
const found = Object.values(launchConfigs || {}).find((c) => c?.default === true && c.harness === harness);
|
|
2187
|
+
return found ? { ...found, harnessDefault: true } : null;
|
|
2188
|
+
}
|
|
2136
2189
|
/** A home's launch layers now (feature launch-preference; --reselect-launch and inspect's launchCurrent): its
|
|
2137
2190
|
* RECORDED soul's launch (the soul copy it runs) and its deployment's oats-local.yaml souls.launch. */
|
|
2138
2191
|
export function homeLaunchLayers(realHome, meta) {
|
|
@@ -2172,7 +2225,11 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2172
2225
|
if (!Object.hasOwn(launchConfigs, name)) { const e = launchConfigUnknown({ name, from: choice.from, at: choice.at }); return report("pi", null, null, { code: e.code, message: e.message, fix: `declare launch configuration ${name} (oats launch-config set), or change oats-local.yaml souls.launch` }); }
|
|
2173
2226
|
config = launchConfigs[name]; harness = config.harness; model = typeof config.model === "string" && config.model ? config.model : null;
|
|
2174
2227
|
} else if (choice.preference) { harness = choice.preference.harness; model = choice.preference.model ?? null; }
|
|
2175
|
-
|
|
2228
|
+
// As a spawn: a layer naming "none" asks for the bare harness; otherwise no configuration is the harness default.
|
|
2229
|
+
if (!config && !(choice.selection.launchConfig === "none" && !choice.preference)) { config = harnessDefaultConfig(launchConfigs, harness); if (config && !model) model = typeof config.model === "string" && config.model ? config.model : null; }
|
|
2230
|
+
let exe;
|
|
2231
|
+
try { exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir }); }
|
|
2232
|
+
catch (e) { if (e?.code !== "E_CLAUDE_CONFIG_REMOVED") throw e; return report(harness, model, config?.name, { code: e.code, message: e.message, fix: `declare a claude default launch configuration (oats launch-config set), then delete ${e.details.file}` }); }
|
|
2176
2233
|
if (!exe.path && !config?.executable) { const e = harnessUnavailable({ harness, from: choice.from, at: choice.at, why: exe.missing }); return report(harness, model, config?.name, { code: e.code, message: e.message, fix: e.details.fix }); }
|
|
2177
2234
|
if (!exe.path) return report(harness, model, config.name, { code: "E_LAUNCH_EXECUTABLE", message: `launch configuration ${config.name}: ${exe.missing}`, fix: `fix the executable of launch configuration ${config.name} (oats launch-config set)` });
|
|
2178
2235
|
return report(harness, model, config?.name);
|
|
@@ -2182,14 +2239,13 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2182
2239
|
* name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
|
|
2183
2240
|
* variable is ever named in the command and no assignment in the same
|
|
2184
2241
|
* prefix can shadow it (zsh evaluates a prefix's assignments in order).
|
|
2185
|
-
* Rendered as tmux `-e` flags
|
|
2242
|
+
* Rendered as tmux `-e` flags. */
|
|
2186
2243
|
export function launchEnvRefs(recipe, env = process.env) {
|
|
2187
2244
|
const out = [];
|
|
2188
2245
|
for (const [name, v] of Object.entries(recipe.env || {})) if (v && typeof v === "object" && v.fromEnv && env[v.fromEnv] !== undefined) out.push({ name: `${LAUNCH_REF_PREFIX}${name}`, value: env[v.fromEnv], target: name, source: v.fromEnv });
|
|
2189
2246
|
return out;
|
|
2190
2247
|
}
|
|
2191
2248
|
export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
|
|
2192
|
-
export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
|
|
2193
2249
|
|
|
2194
2250
|
/** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
|
|
2195
2251
|
export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
|
|
@@ -2351,7 +2407,10 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2351
2407
|
if (selection.launchConfig !== undefined || selection.harness !== undefined) launchChoice = { from: "flag", at: null, declared: meta?.launchDeclared ?? null };
|
|
2352
2408
|
}
|
|
2353
2409
|
const { config, harness, model, modelSource } = chosen;
|
|
2354
|
-
|
|
2410
|
+
// A recorded yolo that came from a harness default belongs to that default: it never carries into a launch
|
|
2411
|
+
// the default no longer decides (a bare --launch-config none, another harness).
|
|
2412
|
+
const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
|
|
2413
|
+
const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
|
|
2355
2414
|
const executable = config?.frozen
|
|
2356
2415
|
? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
|
|
2357
2416
|
: resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
|
|
@@ -2419,7 +2478,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2419
2478
|
} else problems.push({ check: "harness-packages", ok: true, detail: "no harness package requirement declared for this harness; nothing probed" });
|
|
2420
2479
|
}
|
|
2421
2480
|
const recipe = {
|
|
2422
|
-
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
|
|
2481
|
+
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null, ...(config?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
2423
2482
|
executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
|
|
2424
2483
|
args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
2425
2484
|
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
|
|
@@ -2510,10 +2569,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2510
2569
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
2511
2570
|
if (o.task !== undefined && typeof o.task !== "string") throw new Error(`task must be a string (got ${typeof o.task}) — a flag parser handing --task's next flag through shows up here`);
|
|
2512
2571
|
if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
|
|
2513
|
-
const session = o.tmuxSession
|
|
2572
|
+
const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
|
|
2514
2573
|
const backend = o.backend || "tmux";
|
|
2515
|
-
if (
|
|
2516
|
-
if (o.herdrSocket !== undefined
|
|
2574
|
+
if (backend === "herdr") throw herdrSettingRemoved("backend herdr was given");
|
|
2575
|
+
if (o.herdrSocket !== undefined) throw herdrSettingRemoved("herdrSocket was given");
|
|
2576
|
+
if (backend !== "tmux") throw new Error(`unknown session backend "${backend}" (tmux)`);
|
|
2517
2577
|
const launch = o.launch !== false;
|
|
2518
2578
|
// Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
|
|
2519
2579
|
// boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
|
|
@@ -2592,8 +2652,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2592
2652
|
}
|
|
2593
2653
|
|
|
2594
2654
|
// An explicit name may not be a soul name (soul and instance references stay
|
|
2595
|
-
// unambiguous) and is taken when any soul of this deployment holds it, or —
|
|
2596
|
-
//
|
|
2655
|
+
// unambiguous) and is taken when any soul of this deployment holds it, or — launched
|
|
2656
|
+
// or not — a live tmux window of the session carries it:
|
|
2597
2657
|
// a typed refusal, never a silent `-2` (the operator typed it). Checked after
|
|
2598
2658
|
// key recovery, so a retried keyed spawn still reaches its own receipt; the
|
|
2599
2659
|
// placement below re-checks after its reservation (a concurrent spawn of
|
|
@@ -2602,7 +2662,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2602
2662
|
if (deploymentSoulNames(root, o.prepared).has(instance)) throw oatsError("E_INSTANCE_NAME_INVALID", `instance name "${instance}" is a soul name in this deployment; an instance may not share a soul's name`);
|
|
2603
2663
|
const holder = deploymentInstanceHomes(root).get(instance)?.[0];
|
|
2604
2664
|
if (holder) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken in this deployment (${holder}); instance names are unique across every soul — pick another --name`), { instance, home: holder });
|
|
2605
|
-
if (
|
|
2665
|
+
if (tmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
|
|
2606
2666
|
}
|
|
2607
2667
|
|
|
2608
2668
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
@@ -2903,10 +2963,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2903
2963
|
// spawn hooks run, which happens after the home exists.
|
|
2904
2964
|
if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
2905
2965
|
const harnessPackages = verifyHarnessPackages(harness, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
2906
|
-
// Backend presence/startup (ensureHerdr) happens AFTER the decision fence
|
|
2907
|
-
// and the exclusive placement reservation below — a stale or losing apply
|
|
2908
|
-
// must not start a daemon. Preview never starts one either.
|
|
2909
|
-
let herdrBase;
|
|
2910
2966
|
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
2911
2967
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
2912
2968
|
|
|
@@ -2974,7 +3030,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2974
3030
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
2975
3031
|
decision, preflight,
|
|
2976
3032
|
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
2977
|
-
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
3033
|
+
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, launchConfigDefault: launchConfig?.harnessDefault === true, yolo, backend,
|
|
2978
3034
|
launch: launchReport({ declared: launchChoice.declared, harness, model, launchConfig: launchConfig?.name, from: launchChoice.from, at: launchChoice.at }),
|
|
2979
3035
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
2980
3036
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
@@ -2991,11 +3047,9 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2991
3047
|
if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
|
|
2992
3048
|
}
|
|
2993
3049
|
// Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
|
|
2994
|
-
// an absent tmux
|
|
2995
|
-
// populated home exists.
|
|
2996
|
-
|
|
2997
|
-
// must not start a daemon.
|
|
2998
|
-
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}; nothing was created`);
|
|
3050
|
+
// an absent tmux binary must fail with nothing created, never after a
|
|
3051
|
+
// populated home exists.
|
|
3052
|
+
if (launch && !which(backend)) throw new Error(`${backend} not installed (brew install tmux); nothing was created`);
|
|
2999
3053
|
// Exclusive placement reservation: the parent may be created, the home
|
|
3000
3054
|
// itself never with `recursive` — EEXIST means another spawn (a concurrent
|
|
3001
3055
|
// apply of the same decision, or anything else) got here first, and this one
|
|
@@ -3101,10 +3155,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3101
3155
|
}
|
|
3102
3156
|
} catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
3103
3157
|
}
|
|
3104
|
-
// Backend startup only now — the decision is bound and the placement is ours.
|
|
3105
|
-
try { herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined; }
|
|
3106
|
-
catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
3107
|
-
|
|
3108
3158
|
initializeNativeHistory(home);
|
|
3109
3159
|
|
|
3110
3160
|
// Body: instructions are a generated instance-local view; the home carries no soul
|
|
@@ -3330,7 +3380,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3330
3380
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
3331
3381
|
let windowMayExist = false;
|
|
3332
3382
|
let spawnTmux;
|
|
3333
|
-
let spawnHerdr;
|
|
3334
3383
|
const ancillaryCleanup = [];
|
|
3335
3384
|
// One compensation owner, from the first hook result through launch and
|
|
3336
3385
|
// the final lineage write. Preserve the original failure and retain any
|
|
@@ -3350,23 +3399,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3350
3399
|
const outstandingGit = new Set();
|
|
3351
3400
|
// A failed new-window command may still have created its window. Verify
|
|
3352
3401
|
// quiescence before removing credentials or work that harness may be using.
|
|
3353
|
-
if (windowMayExist
|
|
3354
|
-
try { stopHerdr(spawnHerdr); }
|
|
3355
|
-
catch (e) {
|
|
3356
|
-
incomplete.push(`Herdr session: ${e.message}`);
|
|
3357
|
-
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
3358
|
-
if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
|
|
3359
|
-
return quarantineInstanceHome({ home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
|
|
3360
|
-
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
3361
|
-
launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
|
|
3362
|
-
}
|
|
3363
|
-
}
|
|
3364
|
-
if (windowMayExist && backend === "herdr" && !spawnHerdr) {
|
|
3365
|
-
// Allocation starts only an empty shell; the harness command has not run.
|
|
3366
|
-
// A lost receipt cannot authorize closing an unidentified terminal.
|
|
3367
|
-
incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
|
|
3368
|
-
}
|
|
3369
|
-
if (windowMayExist && backend === "tmux") {
|
|
3402
|
+
if (windowMayExist) {
|
|
3370
3403
|
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
3371
3404
|
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
3372
3405
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
@@ -3536,7 +3569,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3536
3569
|
const shimTarget = writeKernelShim(home);
|
|
3537
3570
|
const recipe = {
|
|
3538
3571
|
version: LAUNCH_RECIPE_VERSION, harness,
|
|
3539
|
-
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
|
|
3572
|
+
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null, ...(launchConfig?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
3540
3573
|
executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
|
|
3541
3574
|
args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
|
|
3542
3575
|
model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
@@ -3622,7 +3655,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3622
3655
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
3623
3656
|
executable: cap.executable,
|
|
3624
3657
|
})),
|
|
3625
|
-
|
|
3658
|
+
tmux: { session, window: instance },
|
|
3626
3659
|
launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
|
|
3627
3660
|
};
|
|
3628
3661
|
// Workspace model: materialize recorded modules/providers (and the module
|
|
@@ -3644,16 +3677,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3644
3677
|
spawnTmux = meta.tmux;
|
|
3645
3678
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
3646
3679
|
const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
|
|
3647
|
-
if (launch
|
|
3648
|
-
windowMayExist = true;
|
|
3649
|
-
spawnHerdr = allocateHerdr(herdrBase, { home, instance });
|
|
3650
|
-
meta.sessionTarget = spawnHerdr;
|
|
3651
|
-
meta.launched = true;
|
|
3652
|
-
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
3653
|
-
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
|
|
3654
|
-
try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
|
|
3655
|
-
catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `Herdr could not run the launch command for ${instance} (${e.code === "ENOENT" ? "herdr unavailable" : "pane run failed"}); the command line is withheld from this message`); }
|
|
3656
|
-
} else if (launch) {
|
|
3680
|
+
if (launch) {
|
|
3657
3681
|
if (!tmuxAlive(session)) {
|
|
3658
3682
|
const hq = existsSync(root) ? root : workspaceOf(root);
|
|
3659
3683
|
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
@@ -3715,7 +3739,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3715
3739
|
meta.spawnCompleted = true;
|
|
3716
3740
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
3717
3741
|
}
|
|
3718
|
-
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach:
|
|
3742
|
+
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
3719
3743
|
} catch (error) {
|
|
3720
3744
|
const note = compensateSpawn();
|
|
3721
3745
|
error.message += note;
|
|
@@ -3742,8 +3766,29 @@ export function servedIdentityLine(identity) {
|
|
|
3742
3766
|
if (identity.mode === "global" && identity.grant) return `acts as ${identity.address || identity.alias || identity.resident || "?"} via grant${identity.grant.expiresAt ? `, expires ${identity.grant.expiresAt}` : ""}`;
|
|
3743
3767
|
return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
|
|
3744
3768
|
}
|
|
3769
|
+
/** The window names of `session` on the tmux server at `socket`: `{ windows }`, empty when the server
|
|
3770
|
+
* or the session is gone; `{ error }` when the server could not be read. */
|
|
3771
|
+
function recordedTmuxWindows(socket, session) {
|
|
3772
|
+
try { return { windows: tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean) }; }
|
|
3773
|
+
catch (e) {
|
|
3774
|
+
if (tmuxServerLost(e) || /can't find session/i.test(String(e.stderr ?? ""))) return { windows: [] };
|
|
3775
|
+
return { error: String(e.stderr ?? e.message ?? "").trim() || "tmux list-windows failed" };
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
/** A status row's liveness from its session's windows: unknown (not false) when the server could not be read. */
|
|
3779
|
+
function tmuxLiveness({ windows, error }, window) {
|
|
3780
|
+
return error ? { running: null, runtimeState: "unreachable", runtimeError: error } : { running: windows.includes(window) };
|
|
3781
|
+
}
|
|
3745
3782
|
export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
3746
|
-
|
|
3783
|
+
// Liveness reads each home's RECORDED endpoint (a pre-0.31 home records pi-agents): the recorded
|
|
3784
|
+
// socket, never the ambient $TMUX server, once per socket and session per call, so status costs no
|
|
3785
|
+
// more child processes as homes grow. A home without a recorded socket reads the default server.
|
|
3786
|
+
const tmuxByEndpoint = new Map();
|
|
3787
|
+
const windowsOf = (session, socket) => {
|
|
3788
|
+
const key = JSON.stringify([socket ?? null, session]);
|
|
3789
|
+
if (!tmuxByEndpoint.has(key)) tmuxByEndpoint.set(key, socket ? recordedTmuxWindows(socket, session) : { windows: tmuxWindows(session) });
|
|
3790
|
+
return tmuxByEndpoint.get(key);
|
|
3791
|
+
};
|
|
3747
3792
|
const readInstancesOf = (agentDir) => {
|
|
3748
3793
|
const instancesDir = join(agentDir, "instances");
|
|
3749
3794
|
// An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
|
|
@@ -3775,11 +3820,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3775
3820
|
try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
|
|
3776
3821
|
catch { retirePending = { reason: "retire pending" }; }
|
|
3777
3822
|
}
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
}
|
|
3823
|
+
// A home opened in Herdr (removed in 0.31.0) is listed, never observed: its row says so.
|
|
3824
|
+
const liveness = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(home)))
|
|
3825
|
+
? { running: null, runtimeState: "unsupported", runtimeError: `E_HERDR_REMOVED: ${herdrInstanceRemoved(e.name).message}` }
|
|
3826
|
+
: tmuxLiveness(windowsOf(meta.tmux?.session || tmuxSession, meta.tmux?.socket), meta.tmux?.window || meta.instance || e.name);
|
|
3783
3827
|
const identity = servedIdentityOf(meta);
|
|
3784
3828
|
// Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
|
|
3785
3829
|
// else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
|
|
@@ -3960,7 +4004,7 @@ function sessionDirectoryGuard(home) {
|
|
|
3960
4004
|
|
|
3961
4005
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
3962
4006
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
3963
|
-
function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux,
|
|
4007
|
+
function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
|
|
3964
4008
|
const marker = {
|
|
3965
4009
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
3966
4010
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
@@ -3980,7 +4024,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
3980
4024
|
// instance.json may be only the pre-hook stub, which records neither.
|
|
3981
4025
|
...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
|
|
3982
4026
|
...(typeof soulId === "string" && soulId ? { soulId } : {}),
|
|
3983
|
-
...(sessionTarget ? { sessionTarget } : {}),
|
|
3984
4027
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
3985
4028
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
3986
4029
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
|
|
@@ -4014,7 +4057,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
4014
4057
|
if (recordRetirementBaseline) {
|
|
4015
4058
|
try {
|
|
4016
4059
|
if (work === "directory") assertDirectoryRoots(home, directoryHome);
|
|
4017
|
-
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux
|
|
4060
|
+
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux });
|
|
4018
4061
|
} catch (e) {
|
|
4019
4062
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
4020
4063
|
}
|
|
@@ -4243,7 +4286,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
|
|
|
4243
4286
|
runtime: {
|
|
4244
4287
|
launched: runtime?.launched === true,
|
|
4245
4288
|
...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
|
|
4246
|
-
...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
|
|
4247
4289
|
},
|
|
4248
4290
|
};
|
|
4249
4291
|
const path = retirementBaselinePath(home);
|
|
@@ -4286,23 +4328,40 @@ function branchOnlyCommits(repo, branch) {
|
|
|
4286
4328
|
}
|
|
4287
4329
|
}
|
|
4288
4330
|
|
|
4331
|
+
/** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
|
|
4332
|
+
* Herdr session target or `backend: "herdr"`, or its independent receipt records a session target
|
|
4333
|
+
* (a tmux receipt never does). Either source is enough. */
|
|
4334
|
+
function recordsHerdr(meta, baseline) {
|
|
4335
|
+
return (isPlainObject(meta) && (meta.sessionTarget !== undefined || meta.backend === "herdr"))
|
|
4336
|
+
|| (isPlainObject(baseline?.runtime) && baseline.runtime.sessionTarget !== undefined);
|
|
4337
|
+
}
|
|
4338
|
+
function readJsonOrUndefined(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } }
|
|
4339
|
+
/** Refuse any session operation on a home opened in Herdr, from its files as they are (either may be
|
|
4340
|
+
* missing or unreadable): before any receipt validation, so the refusal is never an endpoint error. */
|
|
4341
|
+
function refuseHerdrHome(home) {
|
|
4342
|
+
if (recordsHerdr(readJsonOrUndefined(join(home, "instance.json")), readJsonOrUndefined(retirementBaselinePath(home)))) throw herdrInstanceRemoved(basename(home));
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4289
4345
|
function runtimeAuthorityOf(baseline) {
|
|
4290
4346
|
const runtime = baseline?.runtime;
|
|
4291
4347
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
4292
4348
|
if (!runtime.launched) return { launched: false };
|
|
4293
|
-
if (runtime.sessionTarget !== undefined)
|
|
4294
|
-
if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
|
|
4295
|
-
return { launched: true, sessionTarget: runtime.sessionTarget };
|
|
4296
|
-
}
|
|
4349
|
+
if (runtime.sessionTarget !== undefined) return undefined;
|
|
4297
4350
|
const tmux = runtime.tmux;
|
|
4298
4351
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
4299
4352
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
4300
4353
|
}
|
|
4301
4354
|
|
|
4355
|
+
/** instance.json's tmux endpoint is the one the independent receipt authorises. */
|
|
4356
|
+
function tmuxEndpointAgrees(meta, authority) {
|
|
4357
|
+
return meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4302
4360
|
/** Session control uses the independent endpoint receipt. */
|
|
4303
4361
|
function instanceSessionTarget(home) {
|
|
4304
4362
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
4305
4363
|
home = realPathOrNearest(home);
|
|
4364
|
+
refuseHerdrHome(home);
|
|
4306
4365
|
let baseline, meta;
|
|
4307
4366
|
try {
|
|
4308
4367
|
baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
|
|
@@ -4310,11 +4369,8 @@ function instanceSessionTarget(home) {
|
|
|
4310
4369
|
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
4311
4370
|
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
4312
4371
|
if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
: !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
4316
|
-
if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
4317
|
-
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
|
|
4372
|
+
if (meta.launched !== authority.launched || (authority.launched && !tmuxEndpointAgrees(meta, authority))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
4373
|
+
return { home, target: authority.launched ? { backend: "tmux", ...authority.tmux } : undefined };
|
|
4318
4374
|
}
|
|
4319
4375
|
|
|
4320
4376
|
export function inspectInstanceSession(home) {
|
|
@@ -4371,7 +4427,7 @@ export function processesInHome(home, io) {
|
|
|
4371
4427
|
* AND no live process on the host works in the home (a harness started by
|
|
4372
4428
|
* hand on another server is invisible to the recorded endpoint).
|
|
4373
4429
|
* Anything else (the window is there, a pane or a process works in the home,
|
|
4374
|
-
* the process scan cannot run, a
|
|
4430
|
+
* the process scan cannot run, a launch without an endpoint,
|
|
4375
4431
|
* tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
|
|
4376
4432
|
* never killed on mutable metadata.
|
|
4377
4433
|
*/
|
|
@@ -4385,7 +4441,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
|
|
|
4385
4441
|
}
|
|
4386
4442
|
function recordedSessionAbsence(home, meta, io) {
|
|
4387
4443
|
const tmux = meta?.tmux;
|
|
4388
|
-
if (meta?.sessionTarget) return { absent: false, backend: "herdr", note: "instance.json records a Herdr session, which cannot be observed without the receipt" };
|
|
4389
4444
|
// A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
|
|
4390
4445
|
if (meta?.launched !== true) return { absent: true, backend: null, note: "no session receipt (a home from before 0.25.9); instance.json records no launch" };
|
|
4391
4446
|
if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
|
|
@@ -4412,7 +4467,7 @@ export function stopInstanceSession(home, o = {}) {
|
|
|
4412
4467
|
if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
|
|
4413
4468
|
let before;
|
|
4414
4469
|
try { before = inspectSessionTarget(s.target); }
|
|
4415
|
-
catch (e) { if (
|
|
4470
|
+
catch (e) { if (tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
|
|
4416
4471
|
if (!before.present || before.state === "shell" || before.state === "stopped") return { home: s.home, backend: s.target.backend, stopped: false, alreadyIdle: true, state: before.state, receipt: null };
|
|
4417
4472
|
const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
4418
4473
|
writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
|
|
@@ -4539,53 +4594,16 @@ function writeJsonAtomic(path, value, mode) {
|
|
|
4539
4594
|
}
|
|
4540
4595
|
|
|
4541
4596
|
// ---------- stopping a harness, selecting on an existing home ----------
|
|
4542
|
-
|
|
4543
|
-
/** The harness processes under a session target: for tmux EVERY descendant
|
|
4597
|
+
/** The harness processes under a tmux session target: EVERY descendant
|
|
4544
4598
|
* of the pane's launcher process (the launcher shell itself is left so
|
|
4545
4599
|
* that, once its command ends, it becomes the fallback shell the start
|
|
4546
4600
|
* path recognizes); a wrapper that does not exec the harness, the harness
|
|
4547
|
-
* and their children are all included, topmost first.
|
|
4548
|
-
*
|
|
4601
|
+
* and their children are all included, topmost first. Each row:
|
|
4602
|
+
* { pid, ppid, pgid, comm, depth }. */
|
|
4549
4603
|
export function harnessProcesses(target, io) {
|
|
4550
4604
|
const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
|
|
4551
4605
|
.split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
|
|
4552
4606
|
.map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
|
|
4553
|
-
if (target.backend === "herdr") {
|
|
4554
|
-
// Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
|
|
4555
|
-
// foreground_process_group_id; foreground_processes) names the pane's
|
|
4556
|
-
// shell. That pid, verified against this host, is the root: OATS launched
|
|
4557
|
-
// `exec /bin/sh -c <command>` in it, so the root is the launcher shell
|
|
4558
|
-
// and the harness its child, exactly as under tmux; a root whose exec
|
|
4559
|
-
// replaced it with a non-shell IS the harness and is included. Without a
|
|
4560
|
-
// shell_pid, the verified non-shell foreground processes are the roots.
|
|
4561
|
-
// Never a fabricated row: an unverifiable pid refuses before any signal.
|
|
4562
|
-
const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
|
|
4563
|
-
if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
|
|
4564
|
-
const rows = ps();
|
|
4565
|
-
const byParent = new Map();
|
|
4566
|
-
for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
|
|
4567
|
-
const out = [];
|
|
4568
|
-
const walk = (pid, depth) => { for (const child of byParent.get(pid) || []) { if (!out.some((o) => o.pid === child.pid)) { out.push({ ...child, depth }); walk(child.pid, depth + 1); } } };
|
|
4569
|
-
const verified = (pid, what) => {
|
|
4570
|
-
if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
|
|
4571
|
-
const row = rows.find((r) => r.pid === pid);
|
|
4572
|
-
if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
|
|
4573
|
-
return row;
|
|
4574
|
-
};
|
|
4575
|
-
if (info.shell_pid !== null && info.shell_pid !== undefined) {
|
|
4576
|
-
const root = verified(info.shell_pid, "the pane shell");
|
|
4577
|
-
if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
|
|
4578
|
-
walk(root.pid, 1);
|
|
4579
|
-
return out;
|
|
4580
|
-
}
|
|
4581
|
-
if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
|
|
4582
|
-
for (const p of info.foreground_processes) {
|
|
4583
|
-
const row = verified(p.pid, `foreground process ${p.name || "?"}`);
|
|
4584
|
-
if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
|
|
4585
|
-
if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
|
|
4586
|
-
}
|
|
4587
|
-
return out;
|
|
4588
|
-
}
|
|
4589
4607
|
const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
|
|
4590
4608
|
if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
|
|
4591
4609
|
const panePid = Number(row);
|
|
@@ -4753,6 +4771,10 @@ export function preWorkspaceHome(home, what) {
|
|
|
4753
4771
|
}
|
|
4754
4772
|
export function startInstanceSession(home, o = {}) {
|
|
4755
4773
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
|
|
4774
|
+
// A home opened in Herdr, or an earlier start that allocated a Herdr pane, is refused before any
|
|
4775
|
+
// guard or receipt reads it.
|
|
4776
|
+
refuseHerdrHome(realPathOrNearest(home));
|
|
4777
|
+
if (readJsonOrUndefined(join(realPathOrNearest(home), ".oats-start-pending.json"))?.target?.backend === "herdr") throw herdrInstanceRemoved(basename(realPathOrNearest(home)));
|
|
4756
4778
|
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
4757
4779
|
const directoryGuard = sessionDirectoryGuard(home);
|
|
4758
4780
|
{
|
|
@@ -4777,25 +4799,23 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4777
4799
|
// re-resolve it against. Retire still works on it.
|
|
4778
4800
|
if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
|
|
4779
4801
|
const lostTmuxServer = tmuxServerLost;
|
|
4780
|
-
const launchFailure = (
|
|
4802
|
+
const launchFailure = (error) => {
|
|
4781
4803
|
// execFileSync errors embed argv (including capability environment) in
|
|
4782
4804
|
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
4783
4805
|
const reason = error.code === "ENOENT" ? "backend executable unavailable"
|
|
4784
4806
|
: error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
|
|
4785
|
-
return oatsError("E_SESSION_START_FAILED",
|
|
4807
|
+
return oatsError("E_SESSION_START_FAILED", `tmux start of ${basename(realHome)} could not be confirmed (${reason}); inspect the recorded session before retrying. Launch evidence is retained in ${pendingPath}`);
|
|
4786
4808
|
};
|
|
4787
4809
|
// The independent receipt first (retire and session consult it), then the
|
|
4788
4810
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
4789
4811
|
// pending receipt exists for.
|
|
4790
|
-
const record = (meta, { id,
|
|
4812
|
+
const record = (meta, { id, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom, launchFrom, launchAt, launchDeclared }, clearPending = true) => {
|
|
4791
4813
|
checkRoots();
|
|
4792
4814
|
const baselinePath = retirementBaselinePath(realHome);
|
|
4793
4815
|
let baseline;
|
|
4794
4816
|
try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
|
|
4795
4817
|
if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== realHome || !runtimeAuthorityOf(baseline)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is invalid for ${realHome}`);
|
|
4796
|
-
baseline.runtime =
|
|
4797
|
-
? { launched: true, sessionTarget: target }
|
|
4798
|
-
: { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
|
|
4818
|
+
baseline.runtime = { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
|
|
4799
4819
|
writeJsonAtomic(baselinePath, baseline, 0o600);
|
|
4800
4820
|
if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
|
|
4801
4821
|
const recorded = meta.startId === id;
|
|
@@ -4807,11 +4827,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4807
4827
|
// Launch-hook meta lands per capability over the spawn's record; a hook
|
|
4808
4828
|
// that answered without meta keeps its previous entry (retire reads it).
|
|
4809
4829
|
...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
|
|
4810
|
-
|
|
4811
|
-
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
4830
|
+
next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) };
|
|
4812
4831
|
writeJsonAtomic(metaPath, next);
|
|
4813
4832
|
if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
|
|
4814
|
-
return { instance: meta.instance, agent: meta.agent, home: realHome, harness: next.harness, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, warnings: [], ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
|
|
4833
|
+
return { instance: meta.instance, agent: meta.agent, home: realHome, harness: next.harness, backend: "tmux", model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, warnings: [], ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
|
|
4815
4834
|
};
|
|
4816
4835
|
try { mkdirSync(lock); }
|
|
4817
4836
|
catch (e) {
|
|
@@ -4829,8 +4848,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4829
4848
|
let pending;
|
|
4830
4849
|
try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
|
|
4831
4850
|
const pt = pending?.target;
|
|
4832
|
-
const validTarget = pt?.backend === "
|
|
4833
|
-
: pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
4851
|
+
const validTarget = pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
4834
4852
|
let validCommand = false;
|
|
4835
4853
|
try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
|
|
4836
4854
|
let validLaunch = true;
|
|
@@ -4842,13 +4860,12 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4842
4860
|
&& (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
|
|
4843
4861
|
&& typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
|
|
4844
4862
|
if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
|
|
4845
|
-
const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
|
|
4846
4863
|
let st;
|
|
4847
4864
|
checkRoots();
|
|
4848
4865
|
try { st = inspectSessionTarget(pending.target, o.io); }
|
|
4849
4866
|
catch (e) {
|
|
4850
|
-
if (
|
|
4851
|
-
else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (
|
|
4867
|
+
if (lostTmuxServer(e)) st = { present: false, state: "stopped" };
|
|
4868
|
+
else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (tmux ${pending.target.session}:${pending.target.window} on ${pending.target.socket}) that cannot be observed now: ${String(e.stderr ?? e.message ?? "").trim() || e.message}; the receipt ${pendingPath} is kept and nothing was started`);
|
|
4852
4869
|
}
|
|
4853
4870
|
// A launch may still consist entirely of shells (startup files, a
|
|
4854
4871
|
// shell-script harness). Only its own completion marker proves this
|
|
@@ -4860,7 +4877,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4860
4877
|
// Reconcile even an exited target: the independent baseline may
|
|
4861
4878
|
// already name it while metadata still names the old allocation.
|
|
4862
4879
|
const meta = readMeta();
|
|
4863
|
-
const done = record(meta, { ...pending,
|
|
4880
|
+
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
4864
4881
|
if (st.present && st.state !== "shell") {
|
|
4865
4882
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
4866
4883
|
else {
|
|
@@ -4878,7 +4895,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4878
4895
|
const meta = readMeta();
|
|
4879
4896
|
const harness = meta.harness;
|
|
4880
4897
|
if (!["pi", "claude", "codex"].includes(harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records harness ${JSON.stringify(harness)}, which this kernel cannot relaunch`);
|
|
4881
|
-
const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
|
|
4882
4898
|
let command = meta.command;
|
|
4883
4899
|
let model = meta.model || undefined;
|
|
4884
4900
|
// What this start launches: the frozen command (optionally with another
|
|
@@ -4922,7 +4938,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4922
4938
|
if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
|
|
4923
4939
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
4924
4940
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
4925
|
-
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
4926
4941
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
4927
4942
|
const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
|
|
4928
4943
|
...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
|
|
@@ -4933,7 +4948,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4933
4948
|
if (target) {
|
|
4934
4949
|
try { state = inspectSessionTarget(target, o.io); }
|
|
4935
4950
|
catch (e) {
|
|
4936
|
-
if (
|
|
4951
|
+
if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
|
|
4937
4952
|
else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
|
|
4938
4953
|
}
|
|
4939
4954
|
}
|
|
@@ -4947,7 +4962,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4947
4962
|
writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { harness: launchPlan?.harness || harness, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
|
|
4948
4963
|
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
4949
4964
|
if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
|
|
4950
|
-
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (
|
|
4965
|
+
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
4951
4966
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
4952
4967
|
}
|
|
4953
4968
|
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
@@ -4959,79 +4974,64 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4959
4974
|
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
|
|
4960
4975
|
const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
4961
4976
|
let reused = "new";
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
}
|
|
4977
|
+
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
4978
|
+
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
4979
|
+
let socket = target?.socket || meta.tmux?.socket;
|
|
4980
|
+
const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
4981
|
+
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
4982
|
+
// the agent's own pane: the command runs there, no other window touched.
|
|
4983
|
+
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
4984
|
+
if (inPlace) {
|
|
4971
4985
|
checkRoots();
|
|
4972
4986
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
4973
|
-
try {
|
|
4974
|
-
catch (e) { throw launchFailure(
|
|
4987
|
+
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
4988
|
+
catch (e) { throw launchFailure(e); }
|
|
4989
|
+
reused = "pane";
|
|
4975
4990
|
} else {
|
|
4976
|
-
const
|
|
4977
|
-
const
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
reused = "pane";
|
|
4989
|
-
} else {
|
|
4990
|
-
const instancesRoot = dirname(realHome);
|
|
4991
|
-
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
4992
|
-
checkRoots();
|
|
4993
|
-
if (!socket) {
|
|
4994
|
-
// Never launched (--no-launch): the default server, as spawn uses.
|
|
4995
|
-
const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
4996
|
-
let alive = false;
|
|
4997
|
-
try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
|
|
4998
|
-
if (!alive) {
|
|
4999
|
-
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
5000
|
-
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
5001
|
-
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
5002
|
-
}
|
|
4991
|
+
const instancesRoot = dirname(realHome);
|
|
4992
|
+
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
4993
|
+
checkRoots();
|
|
4994
|
+
if (!socket) {
|
|
4995
|
+
// Never launched (--no-launch): the default server, as spawn uses.
|
|
4996
|
+
const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
4997
|
+
let alive = false;
|
|
4998
|
+
try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
|
|
4999
|
+
if (!alive) {
|
|
5000
|
+
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
5001
|
+
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
5002
|
+
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
5003
5003
|
}
|
|
5004
|
-
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
5005
|
-
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
5006
|
-
} else if (serverGone) {
|
|
5007
|
-
// The recorded server is gone (a reboot): the same socket path again.
|
|
5008
|
-
mkdirSync(dirname(socket), { recursive: true });
|
|
5009
|
-
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5010
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
5011
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
5012
5004
|
}
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5005
|
+
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
5006
|
+
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
5007
|
+
} else if (serverGone) {
|
|
5008
|
+
// The recorded server is gone (a reboot): the same socket path again.
|
|
5009
|
+
mkdirSync(dirname(socket), { recursive: true });
|
|
5010
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5011
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
5012
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
5013
|
+
}
|
|
5014
|
+
let names = [];
|
|
5015
|
+
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
5016
|
+
catch (e) {
|
|
5017
|
+
if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
|
|
5018
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5025
5019
|
}
|
|
5020
|
+
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
5026
5021
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5022
|
+
checkRoots();
|
|
5023
|
+
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
5024
|
+
try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
5025
|
+
catch (e) { throw launchFailure(e); }
|
|
5027
5026
|
}
|
|
5027
|
+
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5028
5028
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5029
5029
|
// A transient child (for example cat TASK.md) is not proof that startup
|
|
5030
5030
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
5031
|
-
try { return { ...record(meta, { id,
|
|
5031
|
+
try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
|
|
5032
5032
|
catch (e) {
|
|
5033
5033
|
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
5034
|
-
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (
|
|
5034
|
+
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (tmux ${target.session}:${target.window} on ${target.socket}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
|
|
5035
5035
|
}
|
|
5036
5036
|
} finally {
|
|
5037
5037
|
// A hook may have replaced the home itself. Never follow that replacement
|
|
@@ -5589,7 +5589,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5589
5589
|
// persists the intent and hands the whole retirement to a detached process
|
|
5590
5590
|
// that runs it as an ordinary EXTERNAL retirement once the harness is gone
|
|
5591
5591
|
// (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
|
|
5592
|
-
|
|
5592
|
+
// A home opened in Herdr (removed in 0.31.0) has no endpoint OATS can quiesce in-process. A
|
|
5593
|
+
// quarantined home's cleanup descriptor overrides the live record's endpoint: either may name it.
|
|
5594
|
+
const herdrHome = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(found.home))) || recordsHerdr(liveMeta);
|
|
5595
|
+
if (self && (!o.keepDir || herdrHome)) {
|
|
5593
5596
|
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
5594
5597
|
}
|
|
5595
5598
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
@@ -5600,7 +5603,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5600
5603
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5601
5604
|
// endpoint that proves quiescence.
|
|
5602
5605
|
let runtimeAuthority;
|
|
5603
|
-
if (
|
|
5606
|
+
if (herdrHome) {
|
|
5607
|
+
// Nothing to quiesce without Herdr: the retirement goes on only when no process works in the
|
|
5608
|
+
// home, exactly as for a session observed absent.
|
|
5609
|
+
const scan = processesInHome(found.home);
|
|
5610
|
+
if (!scan.ok) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot retire ${name}: it was opened in Herdr (${HERDR_REMOVED}), and whether a process still works in its home could not be established (${scan.error}); nothing was removed`);
|
|
5611
|
+
if (scan.processes.length) throw herdrInstanceBusy(name, scan.processes.map((p) => p.pid));
|
|
5612
|
+
} else if (liveMeta || quarantine) {
|
|
5604
5613
|
runtimeAuthority = initialObservation.runtimeAuthority;
|
|
5605
5614
|
// A home without its receipt (spawned before 0.25.9) has nothing OATS may
|
|
5606
5615
|
// quiesce; when its session is observably absent, quiescing is vacuous and
|
|
@@ -5613,12 +5622,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5613
5622
|
}
|
|
5614
5623
|
}
|
|
5615
5624
|
if (runtimeAuthority) {
|
|
5616
|
-
const
|
|
5617
|
-
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
|
|
5618
|
-
: !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
|
|
5619
|
-
&& meta.tmux?.window === runtimeAuthority.tmux?.window
|
|
5620
|
-
&& resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
|
|
5621
|
-
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
|
|
5625
|
+
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
|
|
5622
5626
|
if (!metaAgrees) {
|
|
5623
5627
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
5624
5628
|
}
|
|
@@ -5626,11 +5630,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5626
5630
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
5627
5631
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
5628
5632
|
// window absence established before recovery copying begins.
|
|
5629
|
-
if (!self && runtimeAuthority?.launched
|
|
5630
|
-
try { stopHerdr(runtimeAuthority.sessionTarget); }
|
|
5631
|
-
catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
|
|
5632
|
-
}
|
|
5633
|
-
if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
|
|
5633
|
+
if (!self && runtimeAuthority?.launched) {
|
|
5634
5634
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
5635
5635
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
5636
5636
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -5930,7 +5930,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5930
5930
|
// The caller is the instance: its process lives in the window we are about to
|
|
5931
5931
|
// kill. Detach the kill so this function can return and the caller can report
|
|
5932
5932
|
// before dying. The delay is the caller's window to print its last words.
|
|
5933
|
-
|
|
5933
|
+
// The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
|
|
5934
|
+
const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
|
|
5935
|
+
const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
|
|
5936
|
+
shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${killSession}:=${killWindow}`)} 2>/dev/null || true'`);
|
|
5934
5937
|
result.selfKillScheduled = true;
|
|
5935
5938
|
}
|
|
5936
5939
|
return result;
|