@awebai/oats 0.30.3 → 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 +76 -44
- package/docs/configuration.md +3 -0
- package/docs/desktop-cli-api.md +112 -11
- package/docs/desktop-instance-start.md +3 -1
- package/docs/desktop.md +20 -0
- package/docs/execution-targets.md +53 -36
- 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.31.0.md +130 -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/core.mjs +166 -209
- 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 -143
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;
|
|
@@ -2182,14 +2196,13 @@ 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
|
-
export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
|
|
2193
2206
|
|
|
2194
2207
|
/** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
|
|
2195
2208
|
export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
|
|
@@ -2510,10 +2523,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2510
2523
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
2511
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`);
|
|
2512
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");
|
|
2513
|
-
const session = o.tmuxSession
|
|
2526
|
+
const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
|
|
2514
2527
|
const backend = o.backend || "tmux";
|
|
2515
|
-
if (
|
|
2516
|
-
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)`);
|
|
2517
2531
|
const launch = o.launch !== false;
|
|
2518
2532
|
// Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
|
|
2519
2533
|
// boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
|
|
@@ -2592,8 +2606,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2592
2606
|
}
|
|
2593
2607
|
|
|
2594
2608
|
// 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
|
-
//
|
|
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:
|
|
2597
2611
|
// a typed refusal, never a silent `-2` (the operator typed it). Checked after
|
|
2598
2612
|
// key recovery, so a retried keyed spawn still reaches its own receipt; the
|
|
2599
2613
|
// placement below re-checks after its reservation (a concurrent spawn of
|
|
@@ -2602,7 +2616,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2602
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`);
|
|
2603
2617
|
const holder = deploymentInstanceHomes(root).get(instance)?.[0];
|
|
2604
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 });
|
|
2605
|
-
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 });
|
|
2606
2620
|
}
|
|
2607
2621
|
|
|
2608
2622
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
@@ -2903,10 +2917,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2903
2917
|
// spawn hooks run, which happens after the home exists.
|
|
2904
2918
|
if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
2905
2919
|
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
2920
|
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
2911
2921
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
2912
2922
|
|
|
@@ -2991,11 +3001,9 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2991
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 });
|
|
2992
3002
|
}
|
|
2993
3003
|
// 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`);
|
|
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`);
|
|
2999
3007
|
// Exclusive placement reservation: the parent may be created, the home
|
|
3000
3008
|
// itself never with `recursive` — EEXIST means another spawn (a concurrent
|
|
3001
3009
|
// apply of the same decision, or anything else) got here first, and this one
|
|
@@ -3101,10 +3109,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3101
3109
|
}
|
|
3102
3110
|
} catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
3103
3111
|
}
|
|
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
3112
|
initializeNativeHistory(home);
|
|
3109
3113
|
|
|
3110
3114
|
// Body: instructions are a generated instance-local view; the home carries no soul
|
|
@@ -3330,7 +3334,6 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3330
3334
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
3331
3335
|
let windowMayExist = false;
|
|
3332
3336
|
let spawnTmux;
|
|
3333
|
-
let spawnHerdr;
|
|
3334
3337
|
const ancillaryCleanup = [];
|
|
3335
3338
|
// One compensation owner, from the first hook result through launch and
|
|
3336
3339
|
// the final lineage write. Preserve the original failure and retain any
|
|
@@ -3350,23 +3353,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3350
3353
|
const outstandingGit = new Set();
|
|
3351
3354
|
// A failed new-window command may still have created its window. Verify
|
|
3352
3355
|
// 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") {
|
|
3356
|
+
if (windowMayExist) {
|
|
3370
3357
|
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
3371
3358
|
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
3372
3359
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
@@ -3622,7 +3609,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3622
3609
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
3623
3610
|
executable: cap.executable,
|
|
3624
3611
|
})),
|
|
3625
|
-
|
|
3612
|
+
tmux: { session, window: instance },
|
|
3626
3613
|
launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
|
|
3627
3614
|
};
|
|
3628
3615
|
// Workspace model: materialize recorded modules/providers (and the module
|
|
@@ -3644,16 +3631,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3644
3631
|
spawnTmux = meta.tmux;
|
|
3645
3632
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
3646
3633
|
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) {
|
|
3634
|
+
if (launch) {
|
|
3657
3635
|
if (!tmuxAlive(session)) {
|
|
3658
3636
|
const hq = existsSync(root) ? root : workspaceOf(root);
|
|
3659
3637
|
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
@@ -3715,7 +3693,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3715
3693
|
meta.spawnCompleted = true;
|
|
3716
3694
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
3717
3695
|
}
|
|
3718
|
-
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 });
|
|
3719
3697
|
} catch (error) {
|
|
3720
3698
|
const note = compensateSpawn();
|
|
3721
3699
|
error.message += note;
|
|
@@ -3742,8 +3720,29 @@ export function servedIdentityLine(identity) {
|
|
|
3742
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}` : ""}`;
|
|
3743
3721
|
return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
|
|
3744
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
|
+
}
|
|
3745
3736
|
export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
3746
|
-
|
|
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
|
+
};
|
|
3747
3746
|
const readInstancesOf = (agentDir) => {
|
|
3748
3747
|
const instancesDir = join(agentDir, "instances");
|
|
3749
3748
|
// An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
|
|
@@ -3775,11 +3774,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3775
3774
|
try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
|
|
3776
3775
|
catch { retirePending = { reason: "retire pending" }; }
|
|
3777
3776
|
}
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
}
|
|
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);
|
|
3783
3781
|
const identity = servedIdentityOf(meta);
|
|
3784
3782
|
// Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
|
|
3785
3783
|
// else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
|
|
@@ -3960,7 +3958,7 @@ function sessionDirectoryGuard(home) {
|
|
|
3960
3958
|
|
|
3961
3959
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
3962
3960
|
* 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,
|
|
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) }) {
|
|
3964
3962
|
const marker = {
|
|
3965
3963
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
3966
3964
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
@@ -3980,7 +3978,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
3980
3978
|
// instance.json may be only the pre-hook stub, which records neither.
|
|
3981
3979
|
...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
|
|
3982
3980
|
...(typeof soulId === "string" && soulId ? { soulId } : {}),
|
|
3983
|
-
...(sessionTarget ? { sessionTarget } : {}),
|
|
3984
3981
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
3985
3982
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
3986
3983
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
|
|
@@ -4014,7 +4011,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
4014
4011
|
if (recordRetirementBaseline) {
|
|
4015
4012
|
try {
|
|
4016
4013
|
if (work === "directory") assertDirectoryRoots(home, directoryHome);
|
|
4017
|
-
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 });
|
|
4018
4015
|
} catch (e) {
|
|
4019
4016
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
4020
4017
|
}
|
|
@@ -4243,7 +4240,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
|
|
|
4243
4240
|
runtime: {
|
|
4244
4241
|
launched: runtime?.launched === true,
|
|
4245
4242
|
...(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
4243
|
},
|
|
4248
4244
|
};
|
|
4249
4245
|
const path = retirementBaselinePath(home);
|
|
@@ -4286,23 +4282,40 @@ function branchOnlyCommits(repo, branch) {
|
|
|
4286
4282
|
}
|
|
4287
4283
|
}
|
|
4288
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
|
+
|
|
4289
4299
|
function runtimeAuthorityOf(baseline) {
|
|
4290
4300
|
const runtime = baseline?.runtime;
|
|
4291
4301
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
4292
4302
|
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
|
-
}
|
|
4303
|
+
if (runtime.sessionTarget !== undefined) return undefined;
|
|
4297
4304
|
const tmux = runtime.tmux;
|
|
4298
4305
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
4299
4306
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
4300
4307
|
}
|
|
4301
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
|
+
|
|
4302
4314
|
/** Session control uses the independent endpoint receipt. */
|
|
4303
4315
|
function instanceSessionTarget(home) {
|
|
4304
4316
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
4305
4317
|
home = realPathOrNearest(home);
|
|
4318
|
+
refuseHerdrHome(home);
|
|
4306
4319
|
let baseline, meta;
|
|
4307
4320
|
try {
|
|
4308
4321
|
baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
|
|
@@ -4310,11 +4323,8 @@ function instanceSessionTarget(home) {
|
|
|
4310
4323
|
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
4311
4324
|
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
4312
4325
|
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 };
|
|
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 };
|
|
4318
4328
|
}
|
|
4319
4329
|
|
|
4320
4330
|
export function inspectInstanceSession(home) {
|
|
@@ -4371,7 +4381,7 @@ export function processesInHome(home, io) {
|
|
|
4371
4381
|
* AND no live process on the host works in the home (a harness started by
|
|
4372
4382
|
* hand on another server is invisible to the recorded endpoint).
|
|
4373
4383
|
* Anything else (the window is there, a pane or a process works in the home,
|
|
4374
|
-
* the process scan cannot run, a
|
|
4384
|
+
* the process scan cannot run, a launch without an endpoint,
|
|
4375
4385
|
* tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
|
|
4376
4386
|
* never killed on mutable metadata.
|
|
4377
4387
|
*/
|
|
@@ -4385,7 +4395,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
|
|
|
4385
4395
|
}
|
|
4386
4396
|
function recordedSessionAbsence(home, meta, io) {
|
|
4387
4397
|
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
4398
|
// A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
|
|
4390
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" };
|
|
4391
4400
|
if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
|
|
@@ -4412,7 +4421,7 @@ export function stopInstanceSession(home, o = {}) {
|
|
|
4412
4421
|
if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
|
|
4413
4422
|
let before;
|
|
4414
4423
|
try { before = inspectSessionTarget(s.target); }
|
|
4415
|
-
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}`); }
|
|
4416
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 };
|
|
4417
4426
|
const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
4418
4427
|
writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
|
|
@@ -4539,53 +4548,16 @@ function writeJsonAtomic(path, value, mode) {
|
|
|
4539
4548
|
}
|
|
4540
4549
|
|
|
4541
4550
|
// ---------- stopping a harness, selecting on an existing home ----------
|
|
4542
|
-
|
|
4543
|
-
/** The harness processes under a session target: for tmux EVERY descendant
|
|
4551
|
+
/** The harness processes under a tmux session target: EVERY descendant
|
|
4544
4552
|
* of the pane's launcher process (the launcher shell itself is left so
|
|
4545
4553
|
* that, once its command ends, it becomes the fallback shell the start
|
|
4546
4554
|
* path recognizes); a wrapper that does not exec the harness, the harness
|
|
4547
|
-
* and their children are all included, topmost first.
|
|
4548
|
-
*
|
|
4555
|
+
* and their children are all included, topmost first. Each row:
|
|
4556
|
+
* { pid, ppid, pgid, comm, depth }. */
|
|
4549
4557
|
export function harnessProcesses(target, io) {
|
|
4550
4558
|
const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
|
|
4551
4559
|
.split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
|
|
4552
4560
|
.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
4561
|
const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
|
|
4590
4562
|
if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
|
|
4591
4563
|
const panePid = Number(row);
|
|
@@ -4753,6 +4725,10 @@ export function preWorkspaceHome(home, what) {
|
|
|
4753
4725
|
}
|
|
4754
4726
|
export function startInstanceSession(home, o = {}) {
|
|
4755
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)));
|
|
4756
4732
|
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
4757
4733
|
const directoryGuard = sessionDirectoryGuard(home);
|
|
4758
4734
|
{
|
|
@@ -4777,25 +4753,23 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4777
4753
|
// re-resolve it against. Retire still works on it.
|
|
4778
4754
|
if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
|
|
4779
4755
|
const lostTmuxServer = tmuxServerLost;
|
|
4780
|
-
const launchFailure = (
|
|
4756
|
+
const launchFailure = (error) => {
|
|
4781
4757
|
// execFileSync errors embed argv (including capability environment) in
|
|
4782
4758
|
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
4783
4759
|
const reason = error.code === "ENOENT" ? "backend executable unavailable"
|
|
4784
4760
|
: error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
|
|
4785
|
-
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}`);
|
|
4786
4762
|
};
|
|
4787
4763
|
// The independent receipt first (retire and session consult it), then the
|
|
4788
4764
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
4789
4765
|
// pending receipt exists for.
|
|
4790
|
-
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) => {
|
|
4791
4767
|
checkRoots();
|
|
4792
4768
|
const baselinePath = retirementBaselinePath(realHome);
|
|
4793
4769
|
let baseline;
|
|
4794
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}`); }
|
|
4795
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}`);
|
|
4796
|
-
baseline.runtime =
|
|
4797
|
-
? { launched: true, sessionTarget: target }
|
|
4798
|
-
: { 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) } };
|
|
4799
4773
|
writeJsonAtomic(baselinePath, baseline, 0o600);
|
|
4800
4774
|
if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
|
|
4801
4775
|
const recorded = meta.startId === id;
|
|
@@ -4807,11 +4781,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4807
4781
|
// Launch-hook meta lands per capability over the spawn's record; a hook
|
|
4808
4782
|
// that answered without meta keeps its previous entry (retire reads it).
|
|
4809
4783
|
...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
|
|
4810
|
-
|
|
4811
|
-
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) };
|
|
4812
4785
|
writeJsonAtomic(metaPath, next);
|
|
4813
4786
|
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 } : {}) };
|
|
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 } : {}) };
|
|
4815
4788
|
};
|
|
4816
4789
|
try { mkdirSync(lock); }
|
|
4817
4790
|
catch (e) {
|
|
@@ -4829,8 +4802,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4829
4802
|
let pending;
|
|
4830
4803
|
try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
|
|
4831
4804
|
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);
|
|
4805
|
+
const validTarget = pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
4834
4806
|
let validCommand = false;
|
|
4835
4807
|
try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
|
|
4836
4808
|
let validLaunch = true;
|
|
@@ -4842,13 +4814,12 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4842
4814
|
&& (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
|
|
4843
4815
|
&& typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
|
|
4844
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`);
|
|
4845
|
-
const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
|
|
4846
4817
|
let st;
|
|
4847
4818
|
checkRoots();
|
|
4848
4819
|
try { st = inspectSessionTarget(pending.target, o.io); }
|
|
4849
4820
|
catch (e) {
|
|
4850
|
-
if (
|
|
4851
|
-
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`);
|
|
4852
4823
|
}
|
|
4853
4824
|
// A launch may still consist entirely of shells (startup files, a
|
|
4854
4825
|
// shell-script harness). Only its own completion marker proves this
|
|
@@ -4860,7 +4831,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4860
4831
|
// Reconcile even an exited target: the independent baseline may
|
|
4861
4832
|
// already name it while metadata still names the old allocation.
|
|
4862
4833
|
const meta = readMeta();
|
|
4863
|
-
const done = record(meta, { ...pending,
|
|
4834
|
+
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
4864
4835
|
if (st.present && st.state !== "shell") {
|
|
4865
4836
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
4866
4837
|
else {
|
|
@@ -4878,7 +4849,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4878
4849
|
const meta = readMeta();
|
|
4879
4850
|
const harness = meta.harness;
|
|
4880
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`);
|
|
4881
|
-
const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
|
|
4882
4852
|
let command = meta.command;
|
|
4883
4853
|
let model = meta.model || undefined;
|
|
4884
4854
|
// What this start launches: the frozen command (optionally with another
|
|
@@ -4922,7 +4892,6 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4922
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`); }
|
|
4923
4893
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
4924
4894
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
4925
|
-
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
4926
4895
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
4927
4896
|
const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
|
|
4928
4897
|
...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
|
|
@@ -4933,7 +4902,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4933
4902
|
if (target) {
|
|
4934
4903
|
try { state = inspectSessionTarget(target, o.io); }
|
|
4935
4904
|
catch (e) {
|
|
4936
|
-
if (
|
|
4905
|
+
if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
|
|
4937
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}`);
|
|
4938
4907
|
}
|
|
4939
4908
|
}
|
|
@@ -4947,7 +4916,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4947
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);
|
|
4948
4917
|
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
4949
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")}`);
|
|
4950
|
-
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}`); }
|
|
4951
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`);
|
|
4952
4921
|
}
|
|
4953
4922
|
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
@@ -4959,79 +4928,64 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4959
4928
|
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
|
|
4960
4929
|
const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
4961
4930
|
let reused = "new";
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
}
|
|
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) {
|
|
4971
4939
|
checkRoots();
|
|
4972
4940
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
4973
|
-
try {
|
|
4974
|
-
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";
|
|
4975
4944
|
} 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
|
-
}
|
|
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(); }
|
|
5003
4957
|
}
|
|
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
|
-
}
|
|
5013
|
-
let names = [];
|
|
5014
|
-
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
5015
|
-
catch (e) {
|
|
5016
|
-
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()}`);
|
|
5017
|
-
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5018
4958
|
}
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
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);
|
|
5025
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);
|
|
4973
|
+
}
|
|
4974
|
+
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
5026
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); }
|
|
5027
4980
|
}
|
|
4981
|
+
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5028
4982
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5029
4983
|
// A transient child (for example cat TASK.md) is not proof that startup
|
|
5030
4984
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
5031
|
-
try { return { ...record(meta, { id,
|
|
4985
|
+
try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
|
|
5032
4986
|
catch (e) {
|
|
5033
4987
|
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
5034
|
-
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`);
|
|
5035
4989
|
}
|
|
5036
4990
|
} finally {
|
|
5037
4991
|
// A hook may have replaced the home itself. Never follow that replacement
|
|
@@ -5589,7 +5543,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5589
5543
|
// persists the intent and hands the whole retirement to a detached process
|
|
5590
5544
|
// that runs it as an ordinary EXTERNAL retirement once the harness is gone
|
|
5591
5545
|
// (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
|
|
5592
|
-
|
|
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)) {
|
|
5593
5550
|
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
5594
5551
|
}
|
|
5595
5552
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
@@ -5600,7 +5557,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5600
5557
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5601
5558
|
// endpoint that proves quiescence.
|
|
5602
5559
|
let runtimeAuthority;
|
|
5603
|
-
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) {
|
|
5604
5567
|
runtimeAuthority = initialObservation.runtimeAuthority;
|
|
5605
5568
|
// A home without its receipt (spawned before 0.25.9) has nothing OATS may
|
|
5606
5569
|
// quiesce; when its session is observably absent, quiescing is vacuous and
|
|
@@ -5613,12 +5576,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5613
5576
|
}
|
|
5614
5577
|
}
|
|
5615
5578
|
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);
|
|
5579
|
+
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
|
|
5622
5580
|
if (!metaAgrees) {
|
|
5623
5581
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
5624
5582
|
}
|
|
@@ -5626,11 +5584,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5626
5584
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
5627
5585
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
5628
5586
|
// 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) {
|
|
5587
|
+
if (!self && runtimeAuthority?.launched) {
|
|
5634
5588
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
5635
5589
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
5636
5590
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -5930,7 +5884,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5930
5884
|
// The caller is the instance: its process lives in the window we are about to
|
|
5931
5885
|
// kill. Detach the kill so this function can return and the caller can report
|
|
5932
5886
|
// before dying. The delay is the caller's window to print its last words.
|
|
5933
|
-
|
|
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'`);
|
|
5934
5891
|
result.selfKillScheduled = true;
|
|
5935
5892
|
}
|
|
5936
5893
|
return result;
|