@awebai/oats 0.30.2 → 0.31.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 +111 -44
- package/docs/configuration.md +21 -1
- package/docs/desktop-cli-api.md +120 -14
- package/docs/desktop-instance-start.md +3 -1
- package/docs/desktop.md +20 -0
- package/docs/execution-targets.md +53 -30
- package/docs/implementation.md +1 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +8 -0
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.30.3.md +75 -0
- package/docs/release-notes/v0.31.0.md +130 -0
- package/docs/schedules.md +4 -1
- package/docs/servers.md +94 -25
- package/docs/souls-and-instances.md +8 -3
- package/lib/attachments.mjs +2 -1
- package/lib/core.mjs +233 -216
- package/lib/errors.mjs +17 -0
- package/lib/instance-inspect.mjs +11 -3
- package/lib/schedule.mjs +21 -8
- package/lib/servers.mjs +265 -45
- package/lib/session-input.mjs +9 -23
- package/lib/session-viewer.mjs +0 -5
- package/lib/triggers.mjs +10 -8
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/lib/herdr.mjs +0 -106
package/lib/core.mjs
CHANGED
|
@@ -41,9 +41,8 @@ import { attachSessionTarget } from "./session-viewer.mjs";
|
|
|
41
41
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
42
42
|
import { appendEvent } from "./instance-events.mjs";
|
|
43
43
|
import { killGroup } from "./process-group.mjs";
|
|
44
|
-
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
45
44
|
|
|
46
|
-
import { oatsError } from "./errors.mjs";
|
|
45
|
+
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
47
46
|
import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
|
|
48
47
|
import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
|
|
49
48
|
async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
|
|
@@ -126,7 +125,22 @@ export function legacyCapturedHomes(root) {
|
|
|
126
125
|
return { code: "legacy-captured-home", instances: homes.map((h) => h.instance), homes: homes.map((h) => h.home),
|
|
127
126
|
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
127
|
}
|
|
129
|
-
|
|
128
|
+
// The tmux session a new tmux instance opens its window in, when oats-local.yaml session.tmuxSession
|
|
129
|
+
// names none (0.31). PI_AGENTS_TMUX_SESSION is the pre-0.31 variable, still honoured. The Desktop keeps
|
|
130
|
+
// the same default in packages/desktop/server/tmux-status.mjs (it does not import lib/): change both.
|
|
131
|
+
export const DEFAULT_TMUX_SESSION = process.env.OATS_TMUX_SESSION || process.env.PI_AGENTS_TMUX_SESSION || "oats-agents";
|
|
132
|
+
/**
|
|
133
|
+
* The tmux session a NEW tmux launch opens its window in (0.31): the caller's, else the deployment's
|
|
134
|
+
* oats-local.yaml `session.tmuxSession`, else OATS_TMUX_SESSION, else PI_AGENTS_TMUX_SESSION, else
|
|
135
|
+
* oats-agents, read from `env` at call time. A launched home keeps the session it recorded: session
|
|
136
|
+
* start/restart never read this.
|
|
137
|
+
*/
|
|
138
|
+
export function sessionDefaults(dir, { tmuxSession } = {}, env = process.env) {
|
|
139
|
+
let local = null;
|
|
140
|
+
try { local = loadLocal(dir); } catch (e) { if (e?.code !== "E_LOCAL_MISSING") throw e; }
|
|
141
|
+
const declared = local?.local?.session ?? {};
|
|
142
|
+
return { tmuxSession: tmuxSession || declared.tmuxSession || env.OATS_TMUX_SESSION || env.PI_AGENTS_TMUX_SESSION || "oats-agents" };
|
|
143
|
+
}
|
|
130
144
|
/** Package root (this file lives in <pkg>/lib/). */
|
|
131
145
|
export const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
132
146
|
export const OATS_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
@@ -1537,7 +1551,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
|
|
|
1537
1551
|
// Hooks also run through direct core callers (not only bin/oats).
|
|
1538
1552
|
// Author this from the running kernel, never PATH, ambient env or
|
|
1539
1553
|
// a caller's extraEnv: those may point at a different executable.
|
|
1540
|
-
OATS_CLI_BIN:
|
|
1554
|
+
OATS_CLI_BIN: kernelBin(),
|
|
1541
1555
|
OATS_SETTINGS: JSON.stringify(cap.settings || {}),
|
|
1542
1556
|
// Where each leaf of OATS_SETTINGS came from (JSON pointer → { kind, at }; kind is
|
|
1543
1557
|
// manifest-default | workspace | soul | host | spawn | anchor), so a provider can tell a
|
|
@@ -2182,14 +2196,60 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2182
2196
|
* name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
|
|
2183
2197
|
* variable is ever named in the command and no assignment in the same
|
|
2184
2198
|
* prefix can shadow it (zsh evaluates a prefix's assignments in order).
|
|
2185
|
-
* Rendered as tmux `-e` flags
|
|
2199
|
+
* Rendered as tmux `-e` flags. */
|
|
2186
2200
|
export function launchEnvRefs(recipe, env = process.env) {
|
|
2187
2201
|
const out = [];
|
|
2188
2202
|
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
2203
|
return out;
|
|
2190
2204
|
}
|
|
2191
2205
|
export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
|
|
2192
|
-
|
|
2206
|
+
|
|
2207
|
+
/** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
|
|
2208
|
+
export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
|
|
2209
|
+
/** Where a home's `oats` lives: first on its harness's PATH, so plain `oats` inside an instance is
|
|
2210
|
+
* the kernel that launched it, whatever other `oats` the host's PATH finds first. */
|
|
2211
|
+
export const kernelShimDir = (home) => join(home, ".oats", "bin");
|
|
2212
|
+
/** Write <home>/.oats/bin/oats, a symlink to this kernel's CLI, replacing any shim a previous
|
|
2213
|
+
* launch (perhaps by another kernel) left: built aside, then renamed over. `.oats` and `.oats/bin`
|
|
2214
|
+
* must be directories of the home itself: a link there would carry the write outside the home.
|
|
2215
|
+
* The target, or E_LAUNCH_SHIM naming the path and the cause (a launch never runs with the
|
|
2216
|
+
* wrong `oats`), before anything outside the home is touched. */
|
|
2217
|
+
export function writeKernelShim(home) {
|
|
2218
|
+
const dir = kernelShimDir(home), shim = join(dir, "oats");
|
|
2219
|
+
let target, aside;
|
|
2220
|
+
try {
|
|
2221
|
+
target = kernelBin();
|
|
2222
|
+
for (const d of [join(home, ".oats"), dir]) {
|
|
2223
|
+
let st;
|
|
2224
|
+
try { st = lstatSync(d); }
|
|
2225
|
+
catch (e) { if (e.code !== "ENOENT") throw e; mkdirSync(d); st = lstatSync(d); }
|
|
2226
|
+
if (!st.isDirectory()) throw new Error(`${d} is ${st.isSymbolicLink() ? "a symbolic link" : "not a directory"}`);
|
|
2227
|
+
}
|
|
2228
|
+
if (realpathSync(dir) !== join(realpathSync(home), ".oats", "bin")) throw new Error(`${dir} resolves outside the home`);
|
|
2229
|
+
aside = join(dir, `.oats-${randomUUID()}`);
|
|
2230
|
+
symlinkSync(target, aside);
|
|
2231
|
+
renameSync(aside, shim);
|
|
2232
|
+
return target;
|
|
2233
|
+
} catch (e) {
|
|
2234
|
+
if (aside) rmSync(aside, { force: true });
|
|
2235
|
+
throw oatsError("E_LAUNCH_SHIM", `cannot write the kernel shim ${shim}${target ? ` (to ${target})` : ""}: ${e.message}; nothing was started`);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
/** A parsed launch command's environment prefix with the home's shim directory first on PATH: a
|
|
2239
|
+
* configuration's PATH (literal or reference) follows it, else the PATH the command runs under. */
|
|
2240
|
+
function shimPathPrefix(tokens, binary, home) {
|
|
2241
|
+
const dir = shq(kernelShimDir(home));
|
|
2242
|
+
const prefix = tokens.slice(0, binary);
|
|
2243
|
+
const texts = prefix.map((t) => t.name !== "PATH" ? t.text : t.kind === "env" ? `PATH=${dir}:${shq(t.value)}` : `PATH=${dir}:"$${t.source}"`);
|
|
2244
|
+
if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
|
|
2245
|
+
return texts;
|
|
2246
|
+
}
|
|
2247
|
+
/** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
|
|
2248
|
+
* first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
|
|
2249
|
+
export function launchShellCommand(command, home) {
|
|
2250
|
+
const { tokens, binary } = parseLaunchCommand(command);
|
|
2251
|
+
return [...shimPathPrefix(tokens, binary, home), ...tokens.slice(binary).map((t) => t.text)].join(" ");
|
|
2252
|
+
}
|
|
2193
2253
|
|
|
2194
2254
|
/** The harness command line of a recipe. With no configuration the bytes
|
|
2195
2255
|
* equal what spawn rendered before recipes: env prefix, the executable, the
|
|
@@ -2234,14 +2294,15 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
2234
2294
|
|
|
2235
2295
|
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
2236
2296
|
* storage inside the backend shell under the actual command environment.
|
|
2237
|
-
* The original executable/argv is exec'd unchanged after recording succeeds
|
|
2297
|
+
* The original executable/argv is exec'd unchanged after recording succeeds,
|
|
2298
|
+
* with the home's kernel shim first on PATH (every launch runs through here). */
|
|
2238
2299
|
function nativeRecordCommand(command, home, harness) {
|
|
2239
2300
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
2240
2301
|
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
2241
2302
|
const id = prepareNativeStart(home, harness);
|
|
2242
2303
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
2243
2304
|
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
|
|
2244
|
-
return `${tokens
|
|
2305
|
+
return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
2245
2306
|
}
|
|
2246
2307
|
|
|
2247
2308
|
|
|
@@ -2374,7 +2435,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2374
2435
|
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
|
|
2375
2436
|
executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
|
|
2376
2437
|
args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
2377
|
-
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
|
|
2438
|
+
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
|
|
2378
2439
|
...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
|
|
2379
2440
|
};
|
|
2380
2441
|
const inst = instance || meta?.instance || basename(home);
|
|
@@ -2412,11 +2473,18 @@ export function redactLaunchCommand(command) {
|
|
|
2412
2473
|
try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
|
|
2413
2474
|
catch { return "<unparseable launch command withheld>"; }
|
|
2414
2475
|
}
|
|
2415
|
-
/** The recipe
|
|
2476
|
+
/** The recipe as answers carry it: every environment value withheld, and without `kernelBin`
|
|
2477
|
+
* (the home's record keeps it; human `oats status` reads it from there). */
|
|
2416
2478
|
export function redactLaunchRecipe(recipe) {
|
|
2417
2479
|
const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
|
|
2418
2480
|
const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
|
|
2419
|
-
|
|
2481
|
+
const { kernelBin: _recorded, ...answered } = recipe;
|
|
2482
|
+
return { ...answered, env, ...(hooks ? { hooks } : {}) };
|
|
2483
|
+
}
|
|
2484
|
+
/** The kernel a home's last launch pointed its `oats` at (instance.json launch.kernelBin), or null. */
|
|
2485
|
+
export function recordedKernelBin(home) {
|
|
2486
|
+
try { const bin = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.launch?.kernelBin; return typeof bin === "string" ? bin : null; }
|
|
2487
|
+
catch { return null; }
|
|
2420
2488
|
}
|
|
2421
2489
|
/** Spawn. With `o.prepared` (workspace model: a resolution prepared by
|
|
2422
2490
|
* instance-resolution.mjs) this is ASYNC — capabilities are fetched and copied
|
|
@@ -2455,10 +2523,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2455
2523
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
2456
2524
|
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`);
|
|
2457
2525
|
if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
|
|
2458
|
-
const session = o.tmuxSession
|
|
2526
|
+
const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
|
|
2459
2527
|
const backend = o.backend || "tmux";
|
|
2460
|
-
if (
|
|
2461
|
-
if (o.herdrSocket !== undefined
|
|
2528
|
+
if (backend === "herdr") throw herdrSettingRemoved("backend herdr was given");
|
|
2529
|
+
if (o.herdrSocket !== undefined) throw herdrSettingRemoved("herdrSocket was given");
|
|
2530
|
+
if (backend !== "tmux") throw new Error(`unknown session backend "${backend}" (tmux)`);
|
|
2462
2531
|
const launch = o.launch !== false;
|
|
2463
2532
|
// Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
|
|
2464
2533
|
// boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
|
|
@@ -2537,8 +2606,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2537
2606
|
}
|
|
2538
2607
|
|
|
2539
2608
|
// An explicit name may not be a soul name (soul and instance references stay
|
|
2540
|
-
// unambiguous) and is taken when any soul of this deployment holds it, or —
|
|
2541
|
-
//
|
|
2609
|
+
// unambiguous) and is taken when any soul of this deployment holds it, or — launched
|
|
2610
|
+
// or not — a live tmux window of the session carries it:
|
|
2542
2611
|
// a typed refusal, never a silent `-2` (the operator typed it). Checked after
|
|
2543
2612
|
// key recovery, so a retried keyed spawn still reaches its own receipt; the
|
|
2544
2613
|
// placement below re-checks after its reservation (a concurrent spawn of
|
|
@@ -2547,7 +2616,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2547
2616
|
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`);
|
|
2548
2617
|
const holder = deploymentInstanceHomes(root).get(instance)?.[0];
|
|
2549
2618
|
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 });
|
|
2550
|
-
if (
|
|
2619
|
+
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 });
|
|
2551
2620
|
}
|
|
2552
2621
|
|
|
2553
2622
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
@@ -2848,10 +2917,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2848
2917
|
// spawn hooks run, which happens after the home exists.
|
|
2849
2918
|
if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
2850
2919
|
const harnessPackages = verifyHarnessPackages(harness, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
2851
|
-
// Backend presence/startup (ensureHerdr) happens AFTER the decision fence
|
|
2852
|
-
// and the exclusive placement reservation below — a stale or losing apply
|
|
2853
|
-
// must not start a daemon. Preview never starts one either.
|
|
2854
|
-
let herdrBase;
|
|
2855
2920
|
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
2856
2921
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
2857
2922
|
|
|
@@ -2936,11 +3001,9 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2936
3001
|
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 });
|
|
2937
3002
|
}
|
|
2938
3003
|
// Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
|
|
2939
|
-
// an absent tmux
|
|
2940
|
-
// populated home exists.
|
|
2941
|
-
|
|
2942
|
-
// must not start a daemon.
|
|
2943
|
-
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}; nothing was created`);
|
|
3004
|
+
// an absent tmux binary must fail with nothing created, never after a
|
|
3005
|
+
// populated home exists.
|
|
3006
|
+
if (launch && !which(backend)) throw new Error(`${backend} not installed (brew install tmux); nothing was created`);
|
|
2944
3007
|
// Exclusive placement reservation: the parent may be created, the home
|
|
2945
3008
|
// itself never with `recursive` — EEXIST means another spawn (a concurrent
|
|
2946
3009
|
// apply of the same decision, or anything else) got here first, and this one
|
|
@@ -3046,10 +3109,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3046
3109
|
}
|
|
3047
3110
|
} catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
3048
3111
|
}
|
|
3049
|
-
// Backend startup only now — the decision is bound and the placement is ours.
|
|
3050
|
-
try { herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined; }
|
|
3051
|
-
catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
3052
|
-
|
|
3053
3112
|
initializeNativeHistory(home);
|
|
3054
3113
|
|
|
3055
3114
|
// Body: instructions are a generated instance-local view; the home carries no soul
|
|
@@ -3275,7 +3334,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3275
3334
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
3276
3335
|
let windowMayExist = false;
|
|
3277
3336
|
let spawnTmux;
|
|
3278
|
-
let spawnHerdr;
|
|
3279
3337
|
const ancillaryCleanup = [];
|
|
3280
3338
|
// One compensation owner, from the first hook result through launch and
|
|
3281
3339
|
// the final lineage write. Preserve the original failure and retain any
|
|
@@ -3295,23 +3353,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3295
3353
|
const outstandingGit = new Set();
|
|
3296
3354
|
// A failed new-window command may still have created its window. Verify
|
|
3297
3355
|
// quiescence before removing credentials or work that harness may be using.
|
|
3298
|
-
if (windowMayExist
|
|
3299
|
-
try { stopHerdr(spawnHerdr); }
|
|
3300
|
-
catch (e) {
|
|
3301
|
-
incomplete.push(`Herdr session: ${e.message}`);
|
|
3302
|
-
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
3303
|
-
if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
|
|
3304
|
-
return quarantineInstanceHome({ home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
|
|
3305
|
-
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
3306
|
-
launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
|
|
3307
|
-
}
|
|
3308
|
-
}
|
|
3309
|
-
if (windowMayExist && backend === "herdr" && !spawnHerdr) {
|
|
3310
|
-
// Allocation starts only an empty shell; the harness command has not run.
|
|
3311
|
-
// A lost receipt cannot authorize closing an unidentified terminal.
|
|
3312
|
-
incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
|
|
3313
|
-
}
|
|
3314
|
-
if (windowMayExist && backend === "tmux") {
|
|
3356
|
+
if (windowMayExist) {
|
|
3315
3357
|
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
3316
3358
|
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
3317
3359
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
@@ -3477,6 +3519,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3477
3519
|
// instance.json beside its rendering, which is executed in the instance's
|
|
3478
3520
|
// tmux window. Capabilities contributed harness-specific arguments and
|
|
3479
3521
|
// environment through their spawn hook; both are recorded with provenance.
|
|
3522
|
+
// The home's `oats` is this kernel, launched now or later (--no-launch).
|
|
3523
|
+
const shimTarget = writeKernelShim(home);
|
|
3480
3524
|
const recipe = {
|
|
3481
3525
|
version: LAUNCH_RECIPE_VERSION, harness,
|
|
3482
3526
|
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
|
|
@@ -3484,7 +3528,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3484
3528
|
args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
|
|
3485
3529
|
model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
3486
3530
|
hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
|
|
3487
|
-
prompt: LAUNCH_PROMPT,
|
|
3531
|
+
prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
|
|
3488
3532
|
};
|
|
3489
3533
|
if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
|
|
3490
3534
|
const cmdline = renderLaunchRecipe(recipe, { home, instance });
|
|
@@ -3565,7 +3609,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3565
3609
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
3566
3610
|
executable: cap.executable,
|
|
3567
3611
|
})),
|
|
3568
|
-
|
|
3612
|
+
tmux: { session, window: instance },
|
|
3569
3613
|
launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
|
|
3570
3614
|
};
|
|
3571
3615
|
// Workspace model: materialize recorded modules/providers (and the module
|
|
@@ -3587,16 +3631,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3587
3631
|
spawnTmux = meta.tmux;
|
|
3588
3632
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
3589
3633
|
const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
|
|
3590
|
-
if (launch
|
|
3591
|
-
windowMayExist = true;
|
|
3592
|
-
spawnHerdr = allocateHerdr(herdrBase, { home, instance });
|
|
3593
|
-
meta.sessionTarget = spawnHerdr;
|
|
3594
|
-
meta.launched = true;
|
|
3595
|
-
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
3596
|
-
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
|
|
3597
|
-
try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
|
|
3598
|
-
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`); }
|
|
3599
|
-
} else if (launch) {
|
|
3634
|
+
if (launch) {
|
|
3600
3635
|
if (!tmuxAlive(session)) {
|
|
3601
3636
|
const hq = existsSync(root) ? root : workspaceOf(root);
|
|
3602
3637
|
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
@@ -3658,7 +3693,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3658
3693
|
meta.spawnCompleted = true;
|
|
3659
3694
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
3660
3695
|
}
|
|
3661
|
-
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach:
|
|
3696
|
+
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
3662
3697
|
} catch (error) {
|
|
3663
3698
|
const note = compensateSpawn();
|
|
3664
3699
|
error.message += note;
|
|
@@ -3685,8 +3720,29 @@ export function servedIdentityLine(identity) {
|
|
|
3685
3720
|
if (identity.mode === "global" && identity.grant) return `acts as ${identity.address || identity.alias || identity.resident || "?"} via grant${identity.grant.expiresAt ? `, expires ${identity.grant.expiresAt}` : ""}`;
|
|
3686
3721
|
return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
|
|
3687
3722
|
}
|
|
3723
|
+
/** The window names of `session` on the tmux server at `socket`: `{ windows }`, empty when the server
|
|
3724
|
+
* or the session is gone; `{ error }` when the server could not be read. */
|
|
3725
|
+
function recordedTmuxWindows(socket, session) {
|
|
3726
|
+
try { return { windows: tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean) }; }
|
|
3727
|
+
catch (e) {
|
|
3728
|
+
if (tmuxServerLost(e) || /can't find session/i.test(String(e.stderr ?? ""))) return { windows: [] };
|
|
3729
|
+
return { error: String(e.stderr ?? e.message ?? "").trim() || "tmux list-windows failed" };
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
/** A status row's liveness from its session's windows: unknown (not false) when the server could not be read. */
|
|
3733
|
+
function tmuxLiveness({ windows, error }, window) {
|
|
3734
|
+
return error ? { running: null, runtimeState: "unreachable", runtimeError: error } : { running: windows.includes(window) };
|
|
3735
|
+
}
|
|
3688
3736
|
export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
3689
|
-
|
|
3737
|
+
// Liveness reads each home's RECORDED endpoint (a pre-0.31 home records pi-agents): the recorded
|
|
3738
|
+
// socket, never the ambient $TMUX server, once per socket and session per call, so status costs no
|
|
3739
|
+
// more child processes as homes grow. A home without a recorded socket reads the default server.
|
|
3740
|
+
const tmuxByEndpoint = new Map();
|
|
3741
|
+
const windowsOf = (session, socket) => {
|
|
3742
|
+
const key = JSON.stringify([socket ?? null, session]);
|
|
3743
|
+
if (!tmuxByEndpoint.has(key)) tmuxByEndpoint.set(key, socket ? recordedTmuxWindows(socket, session) : { windows: tmuxWindows(session) });
|
|
3744
|
+
return tmuxByEndpoint.get(key);
|
|
3745
|
+
};
|
|
3690
3746
|
const readInstancesOf = (agentDir) => {
|
|
3691
3747
|
const instancesDir = join(agentDir, "instances");
|
|
3692
3748
|
// An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
|
|
@@ -3718,11 +3774,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3718
3774
|
try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
|
|
3719
3775
|
catch { retirePending = { reason: "retire pending" }; }
|
|
3720
3776
|
}
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
}
|
|
3777
|
+
// A home opened in Herdr (removed in 0.31.0) is listed, never observed: its row says so.
|
|
3778
|
+
const liveness = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(home)))
|
|
3779
|
+
? { running: null, runtimeState: "unsupported", runtimeError: `E_HERDR_REMOVED: ${herdrInstanceRemoved(e.name).message}` }
|
|
3780
|
+
: tmuxLiveness(windowsOf(meta.tmux?.session || tmuxSession, meta.tmux?.socket), meta.tmux?.window || meta.instance || e.name);
|
|
3726
3781
|
const identity = servedIdentityOf(meta);
|
|
3727
3782
|
// Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
|
|
3728
3783
|
// else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
|
|
@@ -3903,7 +3958,7 @@ function sessionDirectoryGuard(home) {
|
|
|
3903
3958
|
|
|
3904
3959
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
3905
3960
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
3906
|
-
function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux,
|
|
3961
|
+
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) }) {
|
|
3907
3962
|
const marker = {
|
|
3908
3963
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
3909
3964
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
@@ -3923,7 +3978,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
3923
3978
|
// instance.json may be only the pre-hook stub, which records neither.
|
|
3924
3979
|
...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
|
|
3925
3980
|
...(typeof soulId === "string" && soulId ? { soulId } : {}),
|
|
3926
|
-
...(sessionTarget ? { sessionTarget } : {}),
|
|
3927
3981
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
3928
3982
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
3929
3983
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
|
|
@@ -3957,7 +4011,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
3957
4011
|
if (recordRetirementBaseline) {
|
|
3958
4012
|
try {
|
|
3959
4013
|
if (work === "directory") assertDirectoryRoots(home, directoryHome);
|
|
3960
|
-
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux
|
|
4014
|
+
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux });
|
|
3961
4015
|
} catch (e) {
|
|
3962
4016
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
3963
4017
|
}
|
|
@@ -4186,7 +4240,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
|
|
|
4186
4240
|
runtime: {
|
|
4187
4241
|
launched: runtime?.launched === true,
|
|
4188
4242
|
...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
|
|
4189
|
-
...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
|
|
4190
4243
|
},
|
|
4191
4244
|
};
|
|
4192
4245
|
const path = retirementBaselinePath(home);
|
|
@@ -4229,23 +4282,40 @@ function branchOnlyCommits(repo, branch) {
|
|
|
4229
4282
|
}
|
|
4230
4283
|
}
|
|
4231
4284
|
|
|
4285
|
+
/** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
|
|
4286
|
+
* Herdr session target or `backend: "herdr"`, or its independent receipt records a session target
|
|
4287
|
+
* (a tmux receipt never does). Either source is enough. */
|
|
4288
|
+
function recordsHerdr(meta, baseline) {
|
|
4289
|
+
return (isPlainObject(meta) && (meta.sessionTarget !== undefined || meta.backend === "herdr"))
|
|
4290
|
+
|| (isPlainObject(baseline?.runtime) && baseline.runtime.sessionTarget !== undefined);
|
|
4291
|
+
}
|
|
4292
|
+
function readJsonOrUndefined(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } }
|
|
4293
|
+
/** Refuse any session operation on a home opened in Herdr, from its files as they are (either may be
|
|
4294
|
+
* missing or unreadable): before any receipt validation, so the refusal is never an endpoint error. */
|
|
4295
|
+
function refuseHerdrHome(home) {
|
|
4296
|
+
if (recordsHerdr(readJsonOrUndefined(join(home, "instance.json")), readJsonOrUndefined(retirementBaselinePath(home)))) throw herdrInstanceRemoved(basename(home));
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4232
4299
|
function runtimeAuthorityOf(baseline) {
|
|
4233
4300
|
const runtime = baseline?.runtime;
|
|
4234
4301
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
4235
4302
|
if (!runtime.launched) return { launched: false };
|
|
4236
|
-
if (runtime.sessionTarget !== undefined)
|
|
4237
|
-
if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
|
|
4238
|
-
return { launched: true, sessionTarget: runtime.sessionTarget };
|
|
4239
|
-
}
|
|
4303
|
+
if (runtime.sessionTarget !== undefined) return undefined;
|
|
4240
4304
|
const tmux = runtime.tmux;
|
|
4241
4305
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
4242
4306
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
4243
4307
|
}
|
|
4244
4308
|
|
|
4309
|
+
/** instance.json's tmux endpoint is the one the independent receipt authorises. */
|
|
4310
|
+
function tmuxEndpointAgrees(meta, authority) {
|
|
4311
|
+
return meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4245
4314
|
/** Session control uses the independent endpoint receipt. */
|
|
4246
4315
|
function instanceSessionTarget(home) {
|
|
4247
4316
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
4248
4317
|
home = realPathOrNearest(home);
|
|
4318
|
+
refuseHerdrHome(home);
|
|
4249
4319
|
let baseline, meta;
|
|
4250
4320
|
try {
|
|
4251
4321
|
baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
|
|
@@ -4253,11 +4323,8 @@ function instanceSessionTarget(home) {
|
|
|
4253
4323
|
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
4254
4324
|
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
4255
4325
|
if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
: !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
4259
|
-
if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
4260
|
-
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
|
|
4326
|
+
if (meta.launched !== authority.launched || (authority.launched && !tmuxEndpointAgrees(meta, authority))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
4327
|
+
return { home, target: authority.launched ? { backend: "tmux", ...authority.tmux } : undefined };
|
|
4261
4328
|
}
|
|
4262
4329
|
|
|
4263
4330
|
export function inspectInstanceSession(home) {
|
|
@@ -4314,7 +4381,7 @@ export function processesInHome(home, io) {
|
|
|
4314
4381
|
* AND no live process on the host works in the home (a harness started by
|
|
4315
4382
|
* hand on another server is invisible to the recorded endpoint).
|
|
4316
4383
|
* Anything else (the window is there, a pane or a process works in the home,
|
|
4317
|
-
* the process scan cannot run, a
|
|
4384
|
+
* the process scan cannot run, a launch without an endpoint,
|
|
4318
4385
|
* tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
|
|
4319
4386
|
* never killed on mutable metadata.
|
|
4320
4387
|
*/
|
|
@@ -4328,7 +4395,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
|
|
|
4328
4395
|
}
|
|
4329
4396
|
function recordedSessionAbsence(home, meta, io) {
|
|
4330
4397
|
const tmux = meta?.tmux;
|
|
4331
|
-
if (meta?.sessionTarget) return { absent: false, backend: "herdr", note: "instance.json records a Herdr session, which cannot be observed without the receipt" };
|
|
4332
4398
|
// A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
|
|
4333
4399
|
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" };
|
|
4334
4400
|
if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
|
|
@@ -4355,7 +4421,7 @@ export function stopInstanceSession(home, o = {}) {
|
|
|
4355
4421
|
if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
|
|
4356
4422
|
let before;
|
|
4357
4423
|
try { before = inspectSessionTarget(s.target); }
|
|
4358
|
-
catch (e) { if (
|
|
4424
|
+
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}`); }
|
|
4359
4425
|
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 };
|
|
4360
4426
|
const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
4361
4427
|
writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
|
|
@@ -4482,53 +4548,16 @@ function writeJsonAtomic(path, value, mode) {
|
|
|
4482
4548
|
}
|
|
4483
4549
|
|
|
4484
4550
|
// ---------- stopping a harness, selecting on an existing home ----------
|
|
4485
|
-
|
|
4486
|
-
/** The harness processes under a session target: for tmux EVERY descendant
|
|
4551
|
+
/** The harness processes under a tmux session target: EVERY descendant
|
|
4487
4552
|
* of the pane's launcher process (the launcher shell itself is left so
|
|
4488
4553
|
* that, once its command ends, it becomes the fallback shell the start
|
|
4489
4554
|
* path recognizes); a wrapper that does not exec the harness, the harness
|
|
4490
|
-
* and their children are all included, topmost first.
|
|
4491
|
-
*
|
|
4555
|
+
* and their children are all included, topmost first. Each row:
|
|
4556
|
+
* { pid, ppid, pgid, comm, depth }. */
|
|
4492
4557
|
export function harnessProcesses(target, io) {
|
|
4493
4558
|
const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
|
|
4494
4559
|
.split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
|
|
4495
4560
|
.map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
|
|
4496
|
-
if (target.backend === "herdr") {
|
|
4497
|
-
// Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
|
|
4498
|
-
// foreground_process_group_id; foreground_processes) names the pane's
|
|
4499
|
-
// shell. That pid, verified against this host, is the root: OATS launched
|
|
4500
|
-
// `exec /bin/sh -c <command>` in it, so the root is the launcher shell
|
|
4501
|
-
// and the harness its child, exactly as under tmux; a root whose exec
|
|
4502
|
-
// replaced it with a non-shell IS the harness and is included. Without a
|
|
4503
|
-
// shell_pid, the verified non-shell foreground processes are the roots.
|
|
4504
|
-
// Never a fabricated row: an unverifiable pid refuses before any signal.
|
|
4505
|
-
const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
|
|
4506
|
-
if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
|
|
4507
|
-
const rows = ps();
|
|
4508
|
-
const byParent = new Map();
|
|
4509
|
-
for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
|
|
4510
|
-
const out = [];
|
|
4511
|
-
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); } } };
|
|
4512
|
-
const verified = (pid, what) => {
|
|
4513
|
-
if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
|
|
4514
|
-
const row = rows.find((r) => r.pid === pid);
|
|
4515
|
-
if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
|
|
4516
|
-
return row;
|
|
4517
|
-
};
|
|
4518
|
-
if (info.shell_pid !== null && info.shell_pid !== undefined) {
|
|
4519
|
-
const root = verified(info.shell_pid, "the pane shell");
|
|
4520
|
-
if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
|
|
4521
|
-
walk(root.pid, 1);
|
|
4522
|
-
return out;
|
|
4523
|
-
}
|
|
4524
|
-
if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
|
|
4525
|
-
for (const p of info.foreground_processes) {
|
|
4526
|
-
const row = verified(p.pid, `foreground process ${p.name || "?"}`);
|
|
4527
|
-
if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
|
|
4528
|
-
if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
|
|
4529
|
-
}
|
|
4530
|
-
return out;
|
|
4531
|
-
}
|
|
4532
4561
|
const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
|
|
4533
4562
|
if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
|
|
4534
4563
|
const panePid = Number(row);
|
|
@@ -4696,6 +4725,10 @@ export function preWorkspaceHome(home, what) {
|
|
|
4696
4725
|
}
|
|
4697
4726
|
export function startInstanceSession(home, o = {}) {
|
|
4698
4727
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
|
|
4728
|
+
// A home opened in Herdr, or an earlier start that allocated a Herdr pane, is refused before any
|
|
4729
|
+
// guard or receipt reads it.
|
|
4730
|
+
refuseHerdrHome(realPathOrNearest(home));
|
|
4731
|
+
if (readJsonOrUndefined(join(realPathOrNearest(home), ".oats-start-pending.json"))?.target?.backend === "herdr") throw herdrInstanceRemoved(basename(realPathOrNearest(home)));
|
|
4699
4732
|
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
4700
4733
|
const directoryGuard = sessionDirectoryGuard(home);
|
|
4701
4734
|
{
|
|
@@ -4720,25 +4753,23 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4720
4753
|
// re-resolve it against. Retire still works on it.
|
|
4721
4754
|
if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
|
|
4722
4755
|
const lostTmuxServer = tmuxServerLost;
|
|
4723
|
-
const launchFailure = (
|
|
4756
|
+
const launchFailure = (error) => {
|
|
4724
4757
|
// execFileSync errors embed argv (including capability environment) in
|
|
4725
4758
|
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
4726
4759
|
const reason = error.code === "ENOENT" ? "backend executable unavailable"
|
|
4727
4760
|
: error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
|
|
4728
|
-
return oatsError("E_SESSION_START_FAILED",
|
|
4761
|
+
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}`);
|
|
4729
4762
|
};
|
|
4730
4763
|
// The independent receipt first (retire and session consult it), then the
|
|
4731
4764
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
4732
4765
|
// pending receipt exists for.
|
|
4733
|
-
const record = (meta, { id,
|
|
4766
|
+
const record = (meta, { id, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom, launchFrom, launchAt, launchDeclared }, clearPending = true) => {
|
|
4734
4767
|
checkRoots();
|
|
4735
4768
|
const baselinePath = retirementBaselinePath(realHome);
|
|
4736
4769
|
let baseline;
|
|
4737
4770
|
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}`); }
|
|
4738
4771
|
if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== realHome || !runtimeAuthorityOf(baseline)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is invalid for ${realHome}`);
|
|
4739
|
-
baseline.runtime =
|
|
4740
|
-
? { launched: true, sessionTarget: target }
|
|
4741
|
-
: { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
|
|
4772
|
+
baseline.runtime = { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
|
|
4742
4773
|
writeJsonAtomic(baselinePath, baseline, 0o600);
|
|
4743
4774
|
if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
|
|
4744
4775
|
const recorded = meta.startId === id;
|
|
@@ -4750,11 +4781,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4750
4781
|
// Launch-hook meta lands per capability over the spawn's record; a hook
|
|
4751
4782
|
// that answered without meta keeps its previous entry (retire reads it).
|
|
4752
4783
|
...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
|
|
4753
|
-
|
|
4754
|
-
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
4784
|
+
next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) };
|
|
4755
4785
|
writeJsonAtomic(metaPath, next);
|
|
4756
4786
|
if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
|
|
4757
|
-
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 } : {}) };
|
|
4787
|
+
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 } : {}) };
|
|
4758
4788
|
};
|
|
4759
4789
|
try { mkdirSync(lock); }
|
|
4760
4790
|
catch (e) {
|
|
@@ -4772,8 +4802,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4772
4802
|
let pending;
|
|
4773
4803
|
try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
|
|
4774
4804
|
const pt = pending?.target;
|
|
4775
|
-
const validTarget = pt?.backend === "
|
|
4776
|
-
: pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
4805
|
+
const validTarget = pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
4777
4806
|
let validCommand = false;
|
|
4778
4807
|
try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
|
|
4779
4808
|
let validLaunch = true;
|
|
@@ -4785,13 +4814,12 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4785
4814
|
&& (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
|
|
4786
4815
|
&& typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
|
|
4787
4816
|
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`);
|
|
4788
|
-
const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
|
|
4789
4817
|
let st;
|
|
4790
4818
|
checkRoots();
|
|
4791
4819
|
try { st = inspectSessionTarget(pending.target, o.io); }
|
|
4792
4820
|
catch (e) {
|
|
4793
|
-
if (
|
|
4794
|
-
else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (
|
|
4821
|
+
if (lostTmuxServer(e)) st = { present: false, state: "stopped" };
|
|
4822
|
+
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`);
|
|
4795
4823
|
}
|
|
4796
4824
|
// A launch may still consist entirely of shells (startup files, a
|
|
4797
4825
|
// shell-script harness). Only its own completion marker proves this
|
|
@@ -4803,7 +4831,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4803
4831
|
// Reconcile even an exited target: the independent baseline may
|
|
4804
4832
|
// already name it while metadata still names the old allocation.
|
|
4805
4833
|
const meta = readMeta();
|
|
4806
|
-
const done = record(meta, { ...pending,
|
|
4834
|
+
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
4807
4835
|
if (st.present && st.state !== "shell") {
|
|
4808
4836
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
4809
4837
|
else {
|
|
@@ -4821,7 +4849,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4821
4849
|
const meta = readMeta();
|
|
4822
4850
|
const harness = meta.harness;
|
|
4823
4851
|
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`);
|
|
4824
|
-
const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
|
|
4825
4852
|
let command = meta.command;
|
|
4826
4853
|
let model = meta.model || undefined;
|
|
4827
4854
|
// What this start launches: the frozen command (optionally with another
|
|
@@ -4865,7 +4892,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4865
4892
|
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`); }
|
|
4866
4893
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
4867
4894
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
4868
|
-
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
4869
4895
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
4870
4896
|
const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
|
|
4871
4897
|
...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
|
|
@@ -4876,7 +4902,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4876
4902
|
if (target) {
|
|
4877
4903
|
try { state = inspectSessionTarget(target, o.io); }
|
|
4878
4904
|
catch (e) {
|
|
4879
|
-
if (
|
|
4905
|
+
if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
|
|
4880
4906
|
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}`);
|
|
4881
4907
|
}
|
|
4882
4908
|
}
|
|
@@ -4890,88 +4916,76 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4890
4916
|
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);
|
|
4891
4917
|
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
4892
4918
|
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")}`);
|
|
4893
|
-
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (
|
|
4919
|
+
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}`); }
|
|
4894
4920
|
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`);
|
|
4895
4921
|
}
|
|
4922
|
+
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
4923
|
+
checkRoots();
|
|
4924
|
+
writeKernelShim(realHome);
|
|
4896
4925
|
const startedAt = new Date().toISOString();
|
|
4897
4926
|
const id = randomUUID();
|
|
4898
4927
|
checkRoots();
|
|
4899
4928
|
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
|
|
4900
4929
|
const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
4901
4930
|
let reused = "new";
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
}
|
|
4931
|
+
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
4932
|
+
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
4933
|
+
let socket = target?.socket || meta.tmux?.socket;
|
|
4934
|
+
const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
4935
|
+
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
4936
|
+
// the agent's own pane: the command runs there, no other window touched.
|
|
4937
|
+
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
4938
|
+
if (inPlace) {
|
|
4911
4939
|
checkRoots();
|
|
4912
4940
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
4913
|
-
try {
|
|
4914
|
-
catch (e) { throw launchFailure(
|
|
4941
|
+
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
4942
|
+
catch (e) { throw launchFailure(e); }
|
|
4943
|
+
reused = "pane";
|
|
4915
4944
|
} else {
|
|
4916
|
-
const
|
|
4917
|
-
const
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
reused = "pane";
|
|
4929
|
-
} else {
|
|
4930
|
-
const instancesRoot = dirname(realHome);
|
|
4931
|
-
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
4932
|
-
checkRoots();
|
|
4933
|
-
if (!socket) {
|
|
4934
|
-
// Never launched (--no-launch): the default server, as spawn uses.
|
|
4935
|
-
const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
4936
|
-
let alive = false;
|
|
4937
|
-
try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
|
|
4938
|
-
if (!alive) {
|
|
4939
|
-
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
4940
|
-
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
4941
|
-
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
4942
|
-
}
|
|
4945
|
+
const instancesRoot = dirname(realHome);
|
|
4946
|
+
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
4947
|
+
checkRoots();
|
|
4948
|
+
if (!socket) {
|
|
4949
|
+
// Never launched (--no-launch): the default server, as spawn uses.
|
|
4950
|
+
const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
4951
|
+
let alive = false;
|
|
4952
|
+
try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
|
|
4953
|
+
if (!alive) {
|
|
4954
|
+
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
4955
|
+
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
4956
|
+
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
4943
4957
|
}
|
|
4944
|
-
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
4945
|
-
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
4946
|
-
} else if (serverGone) {
|
|
4947
|
-
// The recorded server is gone (a reboot): the same socket path again.
|
|
4948
|
-
mkdirSync(dirname(socket), { recursive: true });
|
|
4949
|
-
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
4950
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
4951
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
4952
4958
|
}
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4959
|
+
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
4960
|
+
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
4961
|
+
} else if (serverGone) {
|
|
4962
|
+
// The recorded server is gone (a reboot): the same socket path again.
|
|
4963
|
+
mkdirSync(dirname(socket), { recursive: true });
|
|
4964
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
4965
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
4966
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
4967
|
+
}
|
|
4968
|
+
let names = [];
|
|
4969
|
+
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
4970
|
+
catch (e) {
|
|
4971
|
+
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()}`);
|
|
4972
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
4965
4973
|
}
|
|
4974
|
+
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
4966
4975
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
4976
|
+
checkRoots();
|
|
4977
|
+
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
4978
|
+
try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
4979
|
+
catch (e) { throw launchFailure(e); }
|
|
4967
4980
|
}
|
|
4981
|
+
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
4968
4982
|
// Keep launch evidence until the command exits or the target disappears.
|
|
4969
4983
|
// A transient child (for example cat TASK.md) is not proof that startup
|
|
4970
4984
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
4971
|
-
try { return { ...record(meta, { id,
|
|
4985
|
+
try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
|
|
4972
4986
|
catch (e) {
|
|
4973
4987
|
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
4974
|
-
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (
|
|
4988
|
+
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`);
|
|
4975
4989
|
}
|
|
4976
4990
|
} finally {
|
|
4977
4991
|
// A hook may have replaced the home itself. Never follow that replacement
|
|
@@ -5529,7 +5543,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5529
5543
|
// persists the intent and hands the whole retirement to a detached process
|
|
5530
5544
|
// that runs it as an ordinary EXTERNAL retirement once the harness is gone
|
|
5531
5545
|
// (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
|
|
5532
|
-
|
|
5546
|
+
// A home opened in Herdr (removed in 0.31.0) has no endpoint OATS can quiesce in-process. A
|
|
5547
|
+
// quarantined home's cleanup descriptor overrides the live record's endpoint: either may name it.
|
|
5548
|
+
const herdrHome = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(found.home))) || recordsHerdr(liveMeta);
|
|
5549
|
+
if (self && (!o.keepDir || herdrHome)) {
|
|
5533
5550
|
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
5534
5551
|
}
|
|
5535
5552
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
@@ -5540,7 +5557,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5540
5557
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5541
5558
|
// endpoint that proves quiescence.
|
|
5542
5559
|
let runtimeAuthority;
|
|
5543
|
-
if (
|
|
5560
|
+
if (herdrHome) {
|
|
5561
|
+
// Nothing to quiesce without Herdr: the retirement goes on only when no process works in the
|
|
5562
|
+
// home, exactly as for a session observed absent.
|
|
5563
|
+
const scan = processesInHome(found.home);
|
|
5564
|
+
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`);
|
|
5565
|
+
if (scan.processes.length) throw herdrInstanceBusy(name, scan.processes.map((p) => p.pid));
|
|
5566
|
+
} else if (liveMeta || quarantine) {
|
|
5544
5567
|
runtimeAuthority = initialObservation.runtimeAuthority;
|
|
5545
5568
|
// A home without its receipt (spawned before 0.25.9) has nothing OATS may
|
|
5546
5569
|
// quiesce; when its session is observably absent, quiescing is vacuous and
|
|
@@ -5553,12 +5576,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5553
5576
|
}
|
|
5554
5577
|
}
|
|
5555
5578
|
if (runtimeAuthority) {
|
|
5556
|
-
const
|
|
5557
|
-
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
|
|
5558
|
-
: !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
|
|
5559
|
-
&& meta.tmux?.window === runtimeAuthority.tmux?.window
|
|
5560
|
-
&& resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
|
|
5561
|
-
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
|
|
5579
|
+
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
|
|
5562
5580
|
if (!metaAgrees) {
|
|
5563
5581
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
5564
5582
|
}
|
|
@@ -5566,11 +5584,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5566
5584
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
5567
5585
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
5568
5586
|
// window absence established before recovery copying begins.
|
|
5569
|
-
if (!self && runtimeAuthority?.launched
|
|
5570
|
-
try { stopHerdr(runtimeAuthority.sessionTarget); }
|
|
5571
|
-
catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
|
|
5572
|
-
}
|
|
5573
|
-
if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
|
|
5587
|
+
if (!self && runtimeAuthority?.launched) {
|
|
5574
5588
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
5575
5589
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
5576
5590
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -5870,7 +5884,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5870
5884
|
// The caller is the instance: its process lives in the window we are about to
|
|
5871
5885
|
// kill. Detach the kill so this function can return and the caller can report
|
|
5872
5886
|
// before dying. The delay is the caller's window to print its last words.
|
|
5873
|
-
|
|
5887
|
+
// The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
|
|
5888
|
+
const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
|
|
5889
|
+
const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
|
|
5890
|
+
shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${killSession}:=${killWindow}`)} 2>/dev/null || true'`);
|
|
5874
5891
|
result.selfKillScheduled = true;
|
|
5875
5892
|
}
|
|
5876
5893
|
return result;
|