@awebai/oats 0.22.19 → 0.23.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/README.md +6 -2
- package/bin/oats.mjs +24 -10
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
- package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
- package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
- package/docs/design/package-runtime-api.md +177 -3
- package/docs/desktop-cli-api.md +1 -1
- package/docs/execution-targets.md +16 -0
- package/docs/knowledge-capability-authoring.md +98 -0
- package/docs/knowledge-reference/acceptance.md +108 -0
- package/docs/knowledge-reference/adoption.md +61 -0
- package/docs/knowledge-reference/harvester.md +107 -0
- package/docs/knowledge-reference/model.md +84 -0
- package/docs/knowledge-reference/package-craft.md +126 -0
- package/docs/knowledge-reference/provider-mapping.md +77 -0
- package/docs/knowledge-reference/reader-capture.md +87 -0
- package/docs/knowledge-theory.md +20 -6
- package/docs/layers.md +8 -7
- package/docs/oats-config.schema.json +5 -2
- package/docs/release-notes/v0.23.0.md +93 -0
- package/docs/souls-and-instances.md +17 -1
- package/injects/work-directory.md +18 -0
- package/lib/core.mjs +279 -56
- package/lib/schedule.mjs +12 -2
- package/package.json +2 -2
- package/packages/record/README.md +19 -0
- package/packages/record/bin/capture.mjs +96 -48
- package/packages/record/bin/recall.mjs +17 -11
- package/packages/record/bin/record-native-start.mjs +11 -0
- package/packages/record/lib/capture-cc.mjs +82 -27
- package/packages/record/lib/capture-lock.mjs +15 -2
- package/packages/record/lib/formats.mjs +108 -21
- package/packages/record/lib/native-history.mjs +87 -0
- package/packages/record/lib/session-roots.mjs +90 -0
- package/packages/record/lib/session-snapshot.mjs +61 -0
- package/packages/record/lib/sessions-for-home.mjs +88 -56
- package/skills/oats/SKILL.md +3 -1
package/lib/core.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* soul.yaml (flat key: value):
|
|
24
24
|
* name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
|
|
25
25
|
* repo (path rel. to workspace or absolute),
|
|
26
|
-
* work (worktree|checkout|attached), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
26
|
+
* work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
27
27
|
* (attached as soul default is for service agents — spawn must supply workDir)
|
|
28
28
|
*/
|
|
29
29
|
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
@@ -35,6 +35,7 @@ import { accessSync, constants as fsConstants } from "node:fs";
|
|
|
35
35
|
import { homedir, tmpdir } from "node:os";
|
|
36
36
|
import { createHash, randomUUID } from "node:crypto";
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
|
|
38
39
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
39
40
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
40
41
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
@@ -42,7 +43,7 @@ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, valid
|
|
|
42
43
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
43
44
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
44
45
|
* must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
|
|
45
|
-
export const WORK_MODES = ["worktree", "checkout", "attached", "workspace"];
|
|
46
|
+
export const WORK_MODES = ["worktree", "checkout", "attached", "workspace", "directory"];
|
|
46
47
|
/** Local (uncommitted) souls dir: <scope>/local-agents, a SIBLING of agents/.
|
|
47
48
|
* Legacy nested <root>/local-agents and <root>/tmp-agents are still read. */
|
|
48
49
|
export const LOCAL_AGENTS_DIR = "local-agents";
|
|
@@ -340,10 +341,12 @@ export function parseFrontmatter(text) {
|
|
|
340
341
|
}
|
|
341
342
|
|
|
342
343
|
// ---------- root discovery ----------
|
|
343
|
-
/** Closest agents/
|
|
344
|
+
/** Closest agents/local-agents layout walking up from `cwd`; without one,
|
|
345
|
+
* the nearest non-laptop config declares a package-only deployment root. */
|
|
344
346
|
export function findRoot(cwd = process.cwd()) {
|
|
345
347
|
if (process.env.PI_AGENTS_ROOT) return resolve(process.env.PI_AGENTS_ROOT);
|
|
346
348
|
let d = resolve(cwd);
|
|
349
|
+
let configuredRoot; // package-only deployments need no pre-created soul directories
|
|
347
350
|
while (true) {
|
|
348
351
|
if (basename(d) === "agents" && lstatSync(d).isDirectory()) return d;
|
|
349
352
|
if (basename(d) === LOCAL_AGENTS_DIR && lstatSync(d).isDirectory() && basename(dirname(d)) !== "agents") {
|
|
@@ -354,8 +357,9 @@ export function findRoot(cwd = process.cwd()) {
|
|
|
354
357
|
// A scope with only local agents is fully operable: its canonical agents
|
|
355
358
|
// root is the (possibly absent) sibling agents/ dir.
|
|
356
359
|
if (existsSync(join(d, LOCAL_AGENTS_DIR)) && lstatSync(join(d, LOCAL_AGENTS_DIR)).isDirectory()) return candidate;
|
|
360
|
+
if (!configuredRoot && d !== homedir() && existsSync(join(d, "oats-config.yaml"))) configuredRoot = candidate;
|
|
357
361
|
const parent = dirname(d);
|
|
358
|
-
if (parent === d) return
|
|
362
|
+
if (parent === d) return configuredRoot;
|
|
359
363
|
d = parent;
|
|
360
364
|
}
|
|
361
365
|
}
|
|
@@ -486,7 +490,7 @@ export function ensureRoot(cwd) {
|
|
|
486
490
|
const root = findRoot(cwd);
|
|
487
491
|
if (!root) {
|
|
488
492
|
throw new Error(
|
|
489
|
-
`no agents
|
|
493
|
+
`no agents/, local-agents/, or deployment oats-config.yaml found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
|
|
490
494
|
);
|
|
491
495
|
}
|
|
492
496
|
// Deployment root ≠ invocation CWD: homes always land in the primary checkout.
|
|
@@ -4621,7 +4625,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4621
4625
|
return accepted;
|
|
4622
4626
|
}
|
|
4623
4627
|
|
|
4624
|
-
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
|
|
4628
|
+
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
|
|
4625
4629
|
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
|
|
4626
4630
|
const envOwners = new Map();
|
|
4627
4631
|
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
|
|
@@ -4634,6 +4638,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4634
4638
|
if (cap.executable && !cap.trust?.trusted) results.warnings.push(`${cap.id}: executable surface disabled — ${cap.trust?.reason || "not trusted"}`);
|
|
4635
4639
|
const cmd = cap.hooks?.[event];
|
|
4636
4640
|
if (!cmd) continue;
|
|
4641
|
+
assertRoots?.();
|
|
4637
4642
|
results.order.push(cap.id);
|
|
4638
4643
|
try {
|
|
4639
4644
|
const stdout = execSync(cmd, {
|
|
@@ -4651,6 +4656,10 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4651
4656
|
OATS_SOUL: soulDir || "", OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
|
|
4652
4657
|
OATS_TEAM_NAME: resolved.team?.name || "", OATS_TEAM_ID: resolved.team?.id || "", OATS_TEAM_SCOPE: resolved.team?.scope || "",
|
|
4653
4658
|
...extraEnv,
|
|
4659
|
+
// Hooks also run through direct core callers (not only bin/oats).
|
|
4660
|
+
// Author this from the running kernel, never PATH, ambient env or
|
|
4661
|
+
// a caller's extraEnv: those may point at a different executable.
|
|
4662
|
+
OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
|
|
4654
4663
|
OATS_SETTINGS: JSON.stringify(cap.settings || {}),
|
|
4655
4664
|
OATS_META: JSON.stringify(priorMeta[cap.id] || {}),
|
|
4656
4665
|
},
|
|
@@ -4710,6 +4719,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4710
4719
|
// to DETECT hook failures, not just print them (warnings are advisory).
|
|
4711
4720
|
const required = (cap.requiredHooks || []).includes(event);
|
|
4712
4721
|
results.failures.push({ capability: cap.id, event, message: detail, required });
|
|
4722
|
+
} finally {
|
|
4723
|
+
assertRoots?.(); // even a failing hook may have exchanged its cwd
|
|
4713
4724
|
}
|
|
4714
4725
|
}
|
|
4715
4726
|
return results;
|
|
@@ -4899,6 +4910,18 @@ export function resolveRepo(root, repo) {
|
|
|
4899
4910
|
return abs;
|
|
4900
4911
|
}
|
|
4901
4912
|
|
|
4913
|
+
/** Only explicit directory execution relaxes the Git requirement. `repo` is
|
|
4914
|
+
* still the config/deployment context, never a directory we take ownership of. */
|
|
4915
|
+
function resolveExecutionContext(root, target, work) {
|
|
4916
|
+
if (work !== "directory") return resolveRepo(root, target);
|
|
4917
|
+
if (target !== undefined && (typeof target !== "string" || !target.trim() || target.includes("\0"))) {
|
|
4918
|
+
throw oatsError("E_BAD_ARGS", "directory mode repo must name an existing context directory");
|
|
4919
|
+
}
|
|
4920
|
+
const context = target === undefined ? workspaceOf(root) : resolve(workspaceOf(root), target);
|
|
4921
|
+
if (!existsSync(context) || !statSync(context).isDirectory()) throw oatsError("E_BAD_ARGS", `directory mode context is not a directory: ${context}`);
|
|
4922
|
+
return resolve(context);
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4902
4925
|
// ---------- OKF (Open Knowledge Format) helpers ----------
|
|
4903
4926
|
export function todayISO() { return new Date().toISOString().slice(0, 10); }
|
|
4904
4927
|
|
|
@@ -4996,7 +5019,7 @@ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo,
|
|
|
4996
5019
|
// The committed soul remains canonical and config-independent. Composition happens in instances.
|
|
4997
5020
|
const claudeMd = join(soulDir, "CLAUDE.md");
|
|
4998
5021
|
try { lstatSync(claudeMd); } catch { symlinkSync("AGENTS.md", claudeMd); }
|
|
4999
|
-
const ctx = repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
|
|
5022
|
+
const ctx = work === "directory" ? resolveExecutionContext(root, repo, work) : repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
|
|
5000
5023
|
const resolved = resolveOatsConfig(ctx, name);
|
|
5001
5024
|
runSoulScaffoldHooks({
|
|
5002
5025
|
home: soulDir, instance: name, agentName: name, soulDir,
|
|
@@ -5020,7 +5043,7 @@ export function createAgent(root, o) {
|
|
|
5020
5043
|
const name = slug(o.name);
|
|
5021
5044
|
if (RESERVED.has(name)) throw new Error(`"${name}" is a reserved name`);
|
|
5022
5045
|
if (findAgent(root, name)) throw new Error(`agent "${name}" already exists`);
|
|
5023
|
-
if (o.repo)
|
|
5046
|
+
if (o.repo !== undefined) resolveExecutionContext(root, o.repo, o.work);
|
|
5024
5047
|
// kind: "local" → a FULL soul (memory, skills, instances) under the scope's
|
|
5025
5048
|
// local-agents/ — uncommitted by contract; otherwise a committed persistent soul.
|
|
5026
5049
|
const kind = o.local || o.kind === "local" ? "local" : "persistent";
|
|
@@ -5446,6 +5469,18 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5446
5469
|
return `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${envTokens.length ? ` ${envTokens.join(" ")}` : ""} ${cmdline}`;
|
|
5447
5470
|
}
|
|
5448
5471
|
|
|
5472
|
+
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
5473
|
+
* storage inside the backend shell under the actual command environment.
|
|
5474
|
+
* The original executable/argv is exec'd unchanged after recording succeeds. */
|
|
5475
|
+
function nativeRecordCommand(command, home, runtime) {
|
|
5476
|
+
const { tokens, binary } = parseLaunchCommand(command);
|
|
5477
|
+
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
5478
|
+
const id = prepareNativeStart(home, runtime);
|
|
5479
|
+
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
5480
|
+
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(runtime)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
|
|
5481
|
+
return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
5482
|
+
}
|
|
5483
|
+
|
|
5449
5484
|
/** A recorded recipe this kernel understands, or a refusal before anything
|
|
5450
5485
|
* is observed or stopped. */
|
|
5451
5486
|
export function assertLaunchRecipe(recipe, what) {
|
|
@@ -5481,7 +5516,8 @@ function requirementsWithArgsMessage(runtime, providers, config) {
|
|
|
5481
5516
|
* collects every failed check into `preflight` instead of throwing. A home
|
|
5482
5517
|
* that predates recipes is not planned here (E_LAUNCH_LEGACY): its frozen
|
|
5483
5518
|
* command is described as is, and converted only by restart. */
|
|
5484
|
-
export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false }) {
|
|
5519
|
+
export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots }) {
|
|
5520
|
+
assertRoots?.();
|
|
5485
5521
|
const problems = [];
|
|
5486
5522
|
const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
|
|
5487
5523
|
// A recorded recipe is validated before anything is observed; a home that
|
|
@@ -5507,7 +5543,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
5507
5543
|
// every capability that gave runtime-specific ones; recorded arguments
|
|
5508
5544
|
// of a capability the scope no longer trusts are not reused.
|
|
5509
5545
|
try {
|
|
5510
|
-
hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir });
|
|
5546
|
+
hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, assertRoots });
|
|
5511
5547
|
const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
|
|
5512
5548
|
const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
|
|
5513
5549
|
const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
|
|
@@ -5605,6 +5641,9 @@ export function redactLaunchRecipe(recipe) {
|
|
|
5605
5641
|
export function spawnInstance(root, agent, o = {}) {
|
|
5606
5642
|
const work = o.work || agent.work || "checkout";
|
|
5607
5643
|
if (!WORK_MODES.includes(work)) throw new Error(`unknown work mode "${work}" (${WORK_MODES.join("|")})`);
|
|
5644
|
+
if (work === "directory" && (o.workDir !== undefined || o.branch !== undefined)) {
|
|
5645
|
+
throw oatsError("E_BAD_ARGS", "directory mode owns only <home>/work; workDir/--work-dir and branch/--branch are not allowed");
|
|
5646
|
+
}
|
|
5608
5647
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
5609
5648
|
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`);
|
|
5610
5649
|
if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
|
|
@@ -5613,7 +5652,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5613
5652
|
if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
|
|
5614
5653
|
if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
|
|
5615
5654
|
const launch = o.launch !== false;
|
|
5616
|
-
const repoAbs =
|
|
5655
|
+
const repoAbs = resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
|
|
5617
5656
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
5618
5657
|
// Launch selection: a named configuration (explicit, or the soul's
|
|
5619
5658
|
// launch-config default), or none; the runtime and model follow from it.
|
|
@@ -5950,6 +5989,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5950
5989
|
const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
5951
5990
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5952
5991
|
|
|
5992
|
+
if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
|
|
5953
5993
|
mkdirSync(home, { recursive: true });
|
|
5954
5994
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
5955
5995
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -5967,6 +6007,8 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5967
6007
|
throw oatsError("E_NO_CANONICAL_ROOT", `instance home ${home} was created at ${createdReal}, not at ${expectedHome} — the path changed after it was validated (a swapped instances/ link), so nothing has been written into it and the spawn is aborted`);
|
|
5968
6008
|
}
|
|
5969
6009
|
|
|
6010
|
+
initializeNativeHistory(home);
|
|
6011
|
+
|
|
5970
6012
|
// Body: the soul is linked for reference, while instructions are a generated instance-local view.
|
|
5971
6013
|
symlinkSync(soulDir, join(home, "soul"));
|
|
5972
6014
|
writeFileSync(join(home, "AGENTS.md"), composition.text);
|
|
@@ -6092,6 +6134,9 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6092
6134
|
const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
|
|
6093
6135
|
throw new Error(`git worktree add/canonicalization failed: ${original}${note}`);
|
|
6094
6136
|
}
|
|
6137
|
+
} else if (work === "directory") {
|
|
6138
|
+
// An owned execution directory, not a link to the source or a fake Git repo.
|
|
6139
|
+
mkdirSync(join(home, "work"));
|
|
6095
6140
|
} else if (work === "attached") {
|
|
6096
6141
|
// Attach to ANOTHER instance's work tree (o.workDir): sibling home, shared tree.
|
|
6097
6142
|
// The tree belongs to its owner — retire never removes it (work/ is a symlink).
|
|
@@ -6122,12 +6167,19 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6122
6167
|
catch (e) { warnings.push(`worktree setup command failed (continuing): ${String(e.message || e).slice(0, 200)}`); }
|
|
6123
6168
|
}
|
|
6124
6169
|
|
|
6170
|
+
// Establish directory ownership while these are still the kernel's own roots.
|
|
6171
|
+
// A hook may replace either path; it must never mint authority for that target.
|
|
6172
|
+
if (work === "directory") {
|
|
6173
|
+
assertDirectoryRoots(home, homeReal);
|
|
6174
|
+
writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false });
|
|
6175
|
+
}
|
|
6176
|
+
|
|
6125
6177
|
// Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
|
|
6126
6178
|
// memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
|
|
6127
6179
|
// messaging integration mints the comms identity. Kernel stays memory-agnostic.
|
|
6128
6180
|
const hookRes = runLifecycleHooks("spawn", {
|
|
6129
6181
|
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
6130
|
-
workspaceDir: workspaceOf(root), resolved: resolvedCfg,
|
|
6182
|
+
workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
|
|
6131
6183
|
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
|
|
6132
6184
|
});
|
|
6133
6185
|
warnings.push(...hookRes.warnings);
|
|
@@ -6162,7 +6214,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6162
6214
|
if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
|
|
6163
6215
|
return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
6164
6216
|
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
6165
|
-
launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
|
|
6217
|
+
launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
|
|
6166
6218
|
}
|
|
6167
6219
|
}
|
|
6168
6220
|
if (windowMayExist && backend === "herdr" && !spawnHerdr) {
|
|
@@ -6186,11 +6238,44 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6186
6238
|
return quarantineInstanceHome({
|
|
6187
6239
|
home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
6188
6240
|
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
6189
|
-
launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
|
|
6241
|
+
launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
|
|
6190
6242
|
});
|
|
6191
6243
|
}
|
|
6192
6244
|
}
|
|
6245
|
+
// Once hooks ran, directory execution may already hold authored results.
|
|
6246
|
+
// Preserve before compensation and again afterwards, just like retirement.
|
|
6247
|
+
// Failure to copy retains the home, never converts a failed spawn into loss.
|
|
6248
|
+
const directoryRecoveries = [];
|
|
6249
|
+
let lastDirectoryFingerprint;
|
|
6193
6250
|
let compensationMeta = {};
|
|
6251
|
+
const retainDirectory = (error) => {
|
|
6252
|
+
incomplete.push(`directory preservation: ${error.message}`);
|
|
6253
|
+
// No successful copy means no destructive cleanup. Even after a partial
|
|
6254
|
+
// compensation pass, retry with the ORIGINAL spawn receipt, not its report.
|
|
6255
|
+
for (const cap of resolvedCfg.capabilities) {
|
|
6256
|
+
if (cap.hooks?.retire || (hookRes.order?.includes(cap.id) && hookRes.meta?.[cap.id])) outstandingHooks.add(cap.id);
|
|
6257
|
+
}
|
|
6258
|
+
const note = quarantineInstanceHome({
|
|
6259
|
+
home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
6260
|
+
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {}, compensationMeta,
|
|
6261
|
+
launched: false, directoryPreservation: true, directoryHome: homeReal,
|
|
6262
|
+
recordRetirementBaseline: true,
|
|
6263
|
+
});
|
|
6264
|
+
return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}`;
|
|
6265
|
+
};
|
|
6266
|
+
const preserveDirectory = () => {
|
|
6267
|
+
if (work !== "directory") return;
|
|
6268
|
+
assertDirectoryRoots(home, homeReal);
|
|
6269
|
+
const path = join(home, "work");
|
|
6270
|
+
if (!readdirSync(path).length) return;
|
|
6271
|
+
const directoryFingerprint = fingerprintTree(path);
|
|
6272
|
+
if (lastDirectoryFingerprint === directoryFingerprint) return;
|
|
6273
|
+
const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
|
|
6274
|
+
directoryRecoveries.push(receipt.path);
|
|
6275
|
+
lastDirectoryFingerprint = directoryFingerprint;
|
|
6276
|
+
};
|
|
6277
|
+
try { preserveDirectory(); }
|
|
6278
|
+
catch (e) { return retainDirectory(e); }
|
|
6194
6279
|
try {
|
|
6195
6280
|
const comp = runLifecycleHooks("retire", {
|
|
6196
6281
|
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
@@ -6244,6 +6329,8 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6244
6329
|
incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
|
|
6245
6330
|
outstandingHooks.add(cap.id);
|
|
6246
6331
|
}
|
|
6332
|
+
try { preserveDirectory(); }
|
|
6333
|
+
catch (e) { return retainDirectory(e); }
|
|
6247
6334
|
let note;
|
|
6248
6335
|
if (outstandingHooks.size || outstandingGit.size) {
|
|
6249
6336
|
// Preserve credentials and the original hook receipt until cleanup
|
|
@@ -6253,14 +6340,14 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6253
6340
|
failed,
|
|
6254
6341
|
outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
|
|
6255
6342
|
hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
|
|
6256
|
-
recordRetirementBaseline: true,
|
|
6343
|
+
directoryHome: homeReal, recordRetirementBaseline: true,
|
|
6257
6344
|
});
|
|
6258
6345
|
} else {
|
|
6259
6346
|
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
6260
6347
|
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
6261
6348
|
note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
|
|
6262
6349
|
}
|
|
6263
|
-
return note
|
|
6350
|
+
return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}`;
|
|
6264
6351
|
};
|
|
6265
6352
|
|
|
6266
6353
|
try {
|
|
@@ -6269,6 +6356,9 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6269
6356
|
const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
|
|
6270
6357
|
throw oatsError(code, `a capability this soul activates could not configure itself:\n${detail}\n\nThe instance would have started with an invalid or missing capability configuration`);
|
|
6271
6358
|
}
|
|
6359
|
+
// Hooks have finished, but no TASK, launch recipe, successful scaffold, or
|
|
6360
|
+
// backend operation may be published until the owned roots are revalidated.
|
|
6361
|
+
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
6272
6362
|
{
|
|
6273
6363
|
const owned = Object.keys(launchConfig?.env || {}).filter((n) => Object.hasOwn(hookRes.env, n));
|
|
6274
6364
|
if (owned.length) {
|
|
@@ -6281,6 +6371,8 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6281
6371
|
? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
|
|
6282
6372
|
: work === "attached"
|
|
6283
6373
|
? `ATTACHED to another instance's work tree (${o.workDir}, branch ${branch}) — you share it with that instance; make your changes and commits focused, and never switch branches`
|
|
6374
|
+
: work === "directory"
|
|
6375
|
+
? `an instance-owned execution directory — not a Git worktree or a link to ${repoAbs}; that path supplies configuration only`
|
|
6284
6376
|
: work === "workspace"
|
|
6285
6377
|
? `the WHOLE WORKSPACE (${realpathSync(join(home, "work"))}) — every member repo is read-context; you coordinate, you do not edit member repos (see your work-mode briefing)`
|
|
6286
6378
|
: `a symlink to the ${repoAbs} checkout — you share it; work on the currently checked-out branch (${branch}) and do not switch branches without being asked`;
|
|
@@ -6377,14 +6469,16 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6377
6469
|
const spawnWarnings = warnings;
|
|
6378
6470
|
|
|
6379
6471
|
spawnTmux = meta.tmux;
|
|
6472
|
+
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
6473
|
+
const executionCommand = launch ? nativeRecordCommand(cmdline, home, runtime) : null;
|
|
6380
6474
|
if (launch && backend === "herdr") {
|
|
6381
6475
|
windowMayExist = true;
|
|
6382
6476
|
spawnHerdr = allocateHerdr(herdrBase, { home, instance });
|
|
6383
6477
|
meta.sessionTarget = spawnHerdr;
|
|
6384
6478
|
meta.launched = true;
|
|
6385
6479
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
6386
|
-
writeRetirementBaseline(home, join(home, "work"), work
|
|
6387
|
-
try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${
|
|
6480
|
+
writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
|
|
6481
|
+
try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
|
|
6388
6482
|
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`); }
|
|
6389
6483
|
} else if (launch) {
|
|
6390
6484
|
if (!tmuxAlive(session)) {
|
|
@@ -6399,17 +6493,17 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6399
6493
|
// Commit the final child metadata and its independent byte authority before
|
|
6400
6494
|
// the managed runtime can write. No child-home transition follows launch.
|
|
6401
6495
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
6402
|
-
writeRetirementBaseline(home, join(home, "work"), work
|
|
6496
|
+
writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
6403
6497
|
// Wrap the command so the window drops into an interactive shell when the
|
|
6404
6498
|
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
6405
|
-
const windowCmd = `${
|
|
6499
|
+
const windowCmd = `${executionCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
6406
6500
|
windowMayExist = true;
|
|
6407
6501
|
try { sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)}${launchEnvTmuxFlags(recipe, process.env)} ${shq(windowCmd)}`); }
|
|
6408
6502
|
catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `tmux new-window failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : "the window command was refused"}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux list-windows on the session to inspect`); }
|
|
6409
6503
|
} else {
|
|
6410
6504
|
meta.launched = false;
|
|
6411
6505
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
6412
|
-
writeRetirementBaseline(home, join(home, "work"), work
|
|
6506
|
+
writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
|
|
6413
6507
|
}
|
|
6414
6508
|
|
|
6415
6509
|
// parent relation: re-point the ANCHOR's recorded lineage so its parent is
|
|
@@ -6466,7 +6560,8 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
6466
6560
|
: { instance: e.name, home };
|
|
6467
6561
|
// A home retained by an incomplete rollback is NOT a live instance: it
|
|
6468
6562
|
// is preserved state awaiting cleanup, and must read that way.
|
|
6469
|
-
const
|
|
6563
|
+
const fallback = directoryRollbackPath(home);
|
|
6564
|
+
const quarantine = existsSync(fallback) ? fallback : join(home, ".oats-rollback-incomplete.json");
|
|
6470
6565
|
let rollbackIncomplete;
|
|
6471
6566
|
if (existsSync(quarantine)) {
|
|
6472
6567
|
try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
|
|
@@ -6601,11 +6696,63 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
|
|
|
6601
6696
|
/** The rollback-owned Git steps a quarantine can still owe. */
|
|
6602
6697
|
export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
|
|
6603
6698
|
|
|
6699
|
+
// A substituted/missing home cannot hold its own receipt. This sibling fallback
|
|
6700
|
+
// is independent of both home bytes and the recovery storage that may have failed.
|
|
6701
|
+
function directoryRollbackPath(home) {
|
|
6702
|
+
return join(dirname(home), `.oats-directory-rollback-${basename(home)}.json`);
|
|
6703
|
+
}
|
|
6704
|
+
|
|
6705
|
+
function assertDirectoryHome(home, canonicalHome = realPathOrNearest(home)) {
|
|
6706
|
+
let st;
|
|
6707
|
+
try { st = lstatSync(home); } catch { /* fail closed below */ }
|
|
6708
|
+
if (!st?.isDirectory() || st.isSymbolicLink() || realpathSync(home) !== canonicalHome) {
|
|
6709
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `directory instance home was removed or exchanged: ${home}; restore the owned home before retrying cleanup`);
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
|
|
6713
|
+
function assertDirectoryRoots(home, canonicalHome) {
|
|
6714
|
+
assertDirectoryHome(home, canonicalHome);
|
|
6715
|
+
const work = join(home, "work");
|
|
6716
|
+
let st;
|
|
6717
|
+
try { st = lstatSync(work); } catch { /* fail closed below */ }
|
|
6718
|
+
if (!st?.isDirectory() || st.isSymbolicLink()) {
|
|
6719
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `directory work must remain an owned directory, not missing, a link or another filesystem type: ${work}; restore the owned work root before retrying cleanup`);
|
|
6720
|
+
}
|
|
6721
|
+
}
|
|
6722
|
+
|
|
6723
|
+
function directoryIdentity(path) {
|
|
6724
|
+
const st = lstatSync(path);
|
|
6725
|
+
return { dev: st.dev, ino: st.ino };
|
|
6726
|
+
}
|
|
6727
|
+
|
|
6728
|
+
// Read independently of mutable instance bytes, BEFORE realpath(home), hooks,
|
|
6729
|
+
// lock creation or backend observation. A metadata mode edit cannot disable it.
|
|
6730
|
+
function sessionDirectoryGuard(home) {
|
|
6731
|
+
let baseline;
|
|
6732
|
+
try { baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8")); }
|
|
6733
|
+
catch (e) { if (e.code === "ENOENT") return () => {}; throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "independent session receipt is unreadable"); }
|
|
6734
|
+
const expected = join(realPathOrNearest(dirname(home)), basename(home));
|
|
6735
|
+
const check = () => {
|
|
6736
|
+
if (baseline.directoryWork === true) {
|
|
6737
|
+
if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== expected) throw oatsError("E_WORK_INSPECTION_FAILED", "invalid independent directory home authority");
|
|
6738
|
+
assertDirectoryRoots(home, expected);
|
|
6739
|
+
for (const [name, path] of [["home", home], ["work", join(home, "work")]]) {
|
|
6740
|
+
const actual = directoryIdentity(path), recorded = baseline.directoryRoots?.[name];
|
|
6741
|
+
if (!recorded || actual.dev !== recorded.dev || actual.ino !== recorded.ino) throw oatsError("E_WORK_INSPECTION_FAILED", `directory ${name} was exchanged; restore the owned root before retrying start`);
|
|
6742
|
+
}
|
|
6743
|
+
}
|
|
6744
|
+
let meta;
|
|
6745
|
+
try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
|
|
6746
|
+
if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
|
|
6747
|
+
};
|
|
6748
|
+
check();
|
|
6749
|
+
return check;
|
|
6750
|
+
}
|
|
6751
|
+
|
|
6604
6752
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
6605
6753
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
6606
|
-
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
|
|
6607
|
-
|
|
6608
|
-
writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
|
|
6754
|
+
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
|
|
6755
|
+
const marker = {
|
|
6609
6756
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
6610
6757
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
6611
6758
|
// quarantine shape is now reached from two events and a fixed "spawn"
|
|
@@ -6620,7 +6767,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
6620
6767
|
version: QUARANTINE_CLEANUP_VERSION,
|
|
6621
6768
|
repo: repoAbs, work, branch, launched, tmux,
|
|
6622
6769
|
...(sessionTarget ? { sessionTarget } : {}),
|
|
6623
|
-
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
|
|
6770
|
+
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
6624
6771
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
6625
6772
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
6626
6773
|
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
@@ -6630,11 +6777,30 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
6630
6777
|
capabilityMeta: hookMeta || {},
|
|
6631
6778
|
},
|
|
6632
6779
|
createdAt: new Date().toISOString(),
|
|
6633
|
-
|
|
6634
|
-
|
|
6780
|
+
};
|
|
6781
|
+
try {
|
|
6782
|
+
const path = join(home, ".oats-rollback-incomplete.json");
|
|
6783
|
+
if (work === "directory") {
|
|
6784
|
+
assertDirectoryHome(home, directoryHome);
|
|
6785
|
+
writeJsonAtomic(path, marker, 0o600);
|
|
6786
|
+
} else writeFileSync(path, JSON.stringify(marker, null, 2) + "\n");
|
|
6787
|
+
} catch {
|
|
6788
|
+
if (work === "directory") {
|
|
6789
|
+
// Never write through a substituted home (even its metadata paths).
|
|
6790
|
+
// The original parent is outside the disposable home; no recovery copy is
|
|
6791
|
+
// needed to retain the frozen hook inputs and the cleanup obligations.
|
|
6792
|
+
try {
|
|
6793
|
+
const path = directoryRollbackPath(directoryHome);
|
|
6794
|
+
if (realpathSync(dirname(path)) !== dirname(path)) throw new Error("directory cleanup parent was redirected; refusing to write through it");
|
|
6795
|
+
writeJsonAtomic(path, marker, 0o600);
|
|
6796
|
+
incomplete.push(`cleanup descriptor retained at ${path}; restore the home before retrying`);
|
|
6797
|
+
} catch (fallbackError) { incomplete.push(`cleanup descriptor could not be stored: ${fallbackError.message}`); }
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6635
6800
|
if (recordRetirementBaseline) {
|
|
6636
6801
|
try {
|
|
6637
|
-
|
|
6802
|
+
if (work === "directory") assertDirectoryRoots(home, directoryHome);
|
|
6803
|
+
writeRetirementBaseline(home, join(home, "work"), work, resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
|
|
6638
6804
|
} catch (e) {
|
|
6639
6805
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
6640
6806
|
}
|
|
@@ -6670,7 +6836,9 @@ function usableCleanupDescriptor(marker) {
|
|
|
6670
6836
|
if (c.work === "worktree" && !nonEmptyString(c.branch)) return false;
|
|
6671
6837
|
if (c.branch !== undefined && !nonEmptyString(c.branch)) return false;
|
|
6672
6838
|
if (c.capabilityMeta !== undefined && !isPlainObject(c.capabilityMeta)) return false;
|
|
6673
|
-
|
|
6839
|
+
const directoryDebt = c.outstanding?.directory === true && c.work === "directory";
|
|
6840
|
+
if (c.outstanding?.directory !== undefined && !directoryDebt) return false;
|
|
6841
|
+
if (!Array.isArray(c.capabilityRuntime) || (!c.capabilityRuntime.length && !directoryDebt)) return false;
|
|
6674
6842
|
if (!c.capabilityRuntime.every((cap) => isPlainObject(cap) && nonEmptyString(cap.id))) return false;
|
|
6675
6843
|
if (!isPlainObject(c.outstanding) || !Array.isArray(c.outstanding.hooks) || !Array.isArray(c.outstanding.git)) return false;
|
|
6676
6844
|
if (!c.outstanding.hooks.every(nonEmptyString)) return false;
|
|
@@ -6679,10 +6847,10 @@ function usableCleanupDescriptor(marker) {
|
|
|
6679
6847
|
// other work mode describes a quarantine that could not have happened.
|
|
6680
6848
|
if (c.outstanding.git.length && c.work !== "worktree") return false;
|
|
6681
6849
|
// The decisive invariant: a quarantine with NOTHING outstanding is a proof
|
|
6682
|
-
// obligation of zero
|
|
6683
|
-
//
|
|
6684
|
-
//
|
|
6685
|
-
if (!c.outstanding.hooks.length && !c.outstanding.git.length) return false;
|
|
6850
|
+
// obligation of zero. Directory preservation is also real debt: the retry's
|
|
6851
|
+
// independent-authority inspection and verified snapshots must succeed even
|
|
6852
|
+
// when no capability has a retire hook. It is never a Git/shared-work escape.
|
|
6853
|
+
if (!c.outstanding.hooks.length && !c.outstanding.git.length && !directoryDebt) return false;
|
|
6686
6854
|
// The retry must be ABLE to rerun what it must prove: an outstanding hook whose
|
|
6687
6855
|
// capability is not in the set could never run, so the quarantine would never
|
|
6688
6856
|
// clear — and the home would be unremovable without --force.
|
|
@@ -6701,7 +6869,7 @@ function retirementStateRoot(home) {
|
|
|
6701
6869
|
}
|
|
6702
6870
|
|
|
6703
6871
|
function retirementKey(home) {
|
|
6704
|
-
return createHash("sha256").update(realPathOrNearest(home)).digest("hex");
|
|
6872
|
+
return createHash("sha256").update(join(realPathOrNearest(dirname(home)), basename(home))).digest("hex");
|
|
6705
6873
|
}
|
|
6706
6874
|
|
|
6707
6875
|
function retirementBaselinePath(home) {
|
|
@@ -6785,11 +6953,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
6785
6953
|
return roots;
|
|
6786
6954
|
}
|
|
6787
6955
|
|
|
6788
|
-
function writeRetirementBaseline(home, work,
|
|
6956
|
+
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
|
|
6957
|
+
if (mode === "directory") assertDirectoryRoots(home);
|
|
6958
|
+
const isWorktree = mode === "worktree";
|
|
6789
6959
|
const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
|
|
6790
6960
|
const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
|
|
6791
6961
|
const baseline = {
|
|
6792
6962
|
version: RETIRE_BASELINE_VERSION,
|
|
6963
|
+
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
6793
6964
|
home: realPathOrNearest(home),
|
|
6794
6965
|
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
|
|
6795
6966
|
disposableReceipts,
|
|
@@ -7172,7 +7343,7 @@ export function capturedProviders(meta, frozen) {
|
|
|
7172
7343
|
return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {} };
|
|
7173
7344
|
});
|
|
7174
7345
|
}
|
|
7175
|
-
export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {} }) {
|
|
7346
|
+
export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
|
|
7176
7347
|
const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
|
|
7177
7348
|
const env = { ...(frozen.hooks?.env || {}) };
|
|
7178
7349
|
const refreshed = [];
|
|
@@ -7194,7 +7365,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
7194
7365
|
if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
|
|
7195
7366
|
}
|
|
7196
7367
|
if (withLaunchHook.length) {
|
|
7197
|
-
const res = runLifecycleHooks("launch", { home, instance: meta.instance, agentName: meta.agent, contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
|
|
7368
|
+
const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
|
|
7198
7369
|
const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
|
|
7199
7370
|
if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${runtime} launch:\n ${failed.join("\n ")}`);
|
|
7200
7371
|
// Ownership holds across retained AND refreshed contributions, as the
|
|
@@ -7263,7 +7434,15 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
7263
7434
|
export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
|
|
7264
7435
|
export function startInstanceSession(home, o = {}) {
|
|
7265
7436
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
|
|
7437
|
+
// Check the sibling receipt BEFORE resolving a possibly substituted home.
|
|
7438
|
+
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
7439
|
+
const checkRoots = sessionDirectoryGuard(home);
|
|
7266
7440
|
const realHome = realPathOrNearest(home);
|
|
7441
|
+
let originalHomeIdentity;
|
|
7442
|
+
try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
|
|
7443
|
+
const rawIo = o.io;
|
|
7444
|
+
const guardedExec = (...args) => { checkRoots(); return (rawIo?.exec || execFileSync)(...args); };
|
|
7445
|
+
o = { ...o, io: { ...rawIo, exec: guardedExec, kill: (...args) => { checkRoots(); return (rawIo?.kill || process.kill)(...args); } } };
|
|
7267
7446
|
const metaPath = join(realHome, "instance.json");
|
|
7268
7447
|
if (!existsSync(metaPath)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json); nothing was started`);
|
|
7269
7448
|
const lock = join(realHome, ".oats-start.lock");
|
|
@@ -7282,6 +7461,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7282
7461
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
7283
7462
|
// pending receipt exists for.
|
|
7284
7463
|
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
|
|
7464
|
+
checkRoots();
|
|
7285
7465
|
const baselinePath = retirementBaselinePath(realHome);
|
|
7286
7466
|
let baseline;
|
|
7287
7467
|
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}`); }
|
|
@@ -7333,6 +7513,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7333
7513
|
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`);
|
|
7334
7514
|
const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
|
|
7335
7515
|
let st;
|
|
7516
|
+
checkRoots();
|
|
7336
7517
|
try { st = inspectSessionTarget(pending.target, o.io); }
|
|
7337
7518
|
catch (e) {
|
|
7338
7519
|
if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
|
|
@@ -7385,7 +7566,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7385
7566
|
const context = meta.repo && existsSync(meta.repo) ? resolve(meta.repo) : dirname(dirname(dirname(dirname(realHome))));
|
|
7386
7567
|
const resolvedCfg = resolveOatsConfig(context, meta.agent);
|
|
7387
7568
|
let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
|
|
7388
|
-
const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env });
|
|
7569
|
+
const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
|
|
7389
7570
|
launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
|
|
7390
7571
|
command = launchPlan.command; model = launchPlan.model;
|
|
7391
7572
|
} else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
|
|
@@ -7401,6 +7582,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7401
7582
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
7402
7583
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
7403
7584
|
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
7585
|
+
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
7404
7586
|
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
|
|
7405
7587
|
let target = receipt.target;
|
|
7406
7588
|
let state = { present: false, state: "not-launched" };
|
|
@@ -7426,7 +7608,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7426
7608
|
}
|
|
7427
7609
|
const startedAt = new Date().toISOString();
|
|
7428
7610
|
const id = randomUUID();
|
|
7429
|
-
|
|
7611
|
+
checkRoots();
|
|
7612
|
+
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
|
|
7613
|
+
const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
7430
7614
|
let reused = "new";
|
|
7431
7615
|
if (backend === "herdr") {
|
|
7432
7616
|
if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
|
|
@@ -7437,6 +7621,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7437
7621
|
catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
|
|
7438
7622
|
target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
|
|
7439
7623
|
}
|
|
7624
|
+
checkRoots();
|
|
7440
7625
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7441
7626
|
try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
|
|
7442
7627
|
catch (e) { throw launchFailure("Herdr", e); }
|
|
@@ -7449,6 +7634,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7449
7634
|
// the agent's own pane: the command runs there, no other window touched.
|
|
7450
7635
|
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
7451
7636
|
if (inPlace) {
|
|
7637
|
+
checkRoots();
|
|
7452
7638
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7453
7639
|
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
7454
7640
|
catch (e) { throw launchFailure("tmux", e); }
|
|
@@ -7456,14 +7642,20 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7456
7642
|
} else {
|
|
7457
7643
|
const instancesRoot = dirname(realHome);
|
|
7458
7644
|
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
7645
|
+
checkRoots();
|
|
7459
7646
|
if (!socket) {
|
|
7460
7647
|
// Never launched (--no-launch): the default server, as spawn uses.
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7648
|
+
const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
7649
|
+
let alive = false;
|
|
7650
|
+
try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
|
|
7651
|
+
if (!alive) {
|
|
7652
|
+
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
7653
|
+
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
7654
|
+
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
7655
|
+
}
|
|
7465
7656
|
}
|
|
7466
|
-
socket =
|
|
7657
|
+
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
7658
|
+
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
7467
7659
|
} else if (serverGone) {
|
|
7468
7660
|
// The recorded server is gone (a reboot): the same socket path again.
|
|
7469
7661
|
mkdirSync(dirname(socket), { recursive: true });
|
|
@@ -7479,6 +7671,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7479
7671
|
}
|
|
7480
7672
|
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
7481
7673
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
7674
|
+
checkRoots();
|
|
7482
7675
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7483
7676
|
try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
7484
7677
|
catch (e) { throw launchFailure("tmux", e); }
|
|
@@ -7494,11 +7687,17 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7494
7687
|
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `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`);
|
|
7495
7688
|
}
|
|
7496
7689
|
} finally {
|
|
7497
|
-
|
|
7690
|
+
// A hook may have replaced the home itself. Never follow that replacement
|
|
7691
|
+
// to remove a target's lock; keep the original retry state with its home.
|
|
7692
|
+
try {
|
|
7693
|
+
const st = lstatSync(realHome);
|
|
7694
|
+
if (st.isDirectory() && !st.isSymbolicLink() && st.dev === originalHomeIdentity?.dev && st.ino === originalHomeIdentity?.ino) rmSync(lock, { recursive: true, force: true });
|
|
7695
|
+
} catch { /* retain retry state when its authority is lost */ }
|
|
7498
7696
|
}
|
|
7499
7697
|
}
|
|
7500
7698
|
|
|
7501
|
-
function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
|
|
7699
|
+
function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false } = {}) {
|
|
7700
|
+
if (directory) assertDirectoryRoots(home);
|
|
7502
7701
|
const classes = [];
|
|
7503
7702
|
let baseline;
|
|
7504
7703
|
const path = retirementBaselinePath(home);
|
|
@@ -7513,7 +7712,18 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
|
|
|
7513
7712
|
} else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
|
|
7514
7713
|
classes.push("changed instance-home bytes");
|
|
7515
7714
|
}
|
|
7516
|
-
|
|
7715
|
+
// A mutable mode must not turn owned directory bytes into an excluded shared
|
|
7716
|
+
// tree (or authorize Git deletion). Require the independent spawn authority.
|
|
7717
|
+
if (directory !== (baselineValid && baseline.directoryWork === true)) {
|
|
7718
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent retirement authority");
|
|
7719
|
+
}
|
|
7720
|
+
let directoryFingerprint;
|
|
7721
|
+
if (directory) {
|
|
7722
|
+
directoryFingerprint = fingerprintTree(work);
|
|
7723
|
+
// Never stamp hook-created or authored execution bytes as disposable.
|
|
7724
|
+
if (readdirSync(work).length) classes.push("directory work bytes");
|
|
7725
|
+
}
|
|
7726
|
+
const branchCommits = !directory && branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
|
|
7517
7727
|
if (branchCommits?.length) classes.push("branch-only local commits");
|
|
7518
7728
|
if (isWorktree && existsSync(work)) {
|
|
7519
7729
|
const status = worktreeStatus(work);
|
|
@@ -7525,9 +7735,9 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
|
|
|
7525
7735
|
}
|
|
7526
7736
|
const stateFingerprint = createHash("sha256")
|
|
7527
7737
|
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
|
|
7528
|
-
.update("\0").update(isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
7738
|
+
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
7529
7739
|
.digest("hex");
|
|
7530
|
-
return { classes: [...new Set(classes)], home, work, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
7740
|
+
return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
7531
7741
|
}
|
|
7532
7742
|
|
|
7533
7743
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -7632,6 +7842,9 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
|
|
|
7632
7842
|
function preserveRetirementWork(observation, meta, instance) {
|
|
7633
7843
|
const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
|
|
7634
7844
|
mkdirSync(recoveryRoot, { recursive: true });
|
|
7845
|
+
if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
|
|
7846
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", "directory recovery storage was redirected; retain the source home rather than copying into an unowned or disposable location");
|
|
7847
|
+
}
|
|
7635
7848
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
7636
7849
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
7637
7850
|
try {
|
|
@@ -7674,6 +7887,13 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
7674
7887
|
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
7675
7888
|
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
7676
7889
|
}
|
|
7890
|
+
if (observation.directory && observation.directoryFingerprint) {
|
|
7891
|
+
const recoveredWork = join(staging, "work");
|
|
7892
|
+
copyTreeSafe(observation.work, recoveredWork);
|
|
7893
|
+
if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
|
|
7894
|
+
throw new Error("directory recovery verification disagreed with the inspected source");
|
|
7895
|
+
}
|
|
7896
|
+
}
|
|
7677
7897
|
const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
|
|
7678
7898
|
writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
7679
7899
|
mkdirSync(dirname(recovery), { recursive: true });
|
|
@@ -7845,7 +8065,8 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7845
8065
|
// not finish) has no instance.json — it never got that far. Its marker carries
|
|
7846
8066
|
// the cleanup descriptor in the same shape, so retire can rerun compensation
|
|
7847
8067
|
// instead of silently skipping every hook and deleting the credentials.
|
|
7848
|
-
const
|
|
8068
|
+
const directoryFallbackPath = directoryRollbackPath(found.home);
|
|
8069
|
+
const quarantinePath = existsSync(directoryFallbackPath) ? directoryFallbackPath : join(found.home, ".oats-rollback-incomplete.json");
|
|
7849
8070
|
let quarantine;
|
|
7850
8071
|
let markerUnusable = false;
|
|
7851
8072
|
// A marker means the home is quarantined, WHETHER OR NOT instance.json exists:
|
|
@@ -7892,8 +8113,9 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7892
8113
|
}
|
|
7893
8114
|
|
|
7894
8115
|
const workPath = join(found.home, "work");
|
|
7895
|
-
const
|
|
7896
|
-
|
|
8116
|
+
const directory = meta.work === "directory";
|
|
8117
|
+
const isWorktree = !directory && (meta.work === "worktree" ||
|
|
8118
|
+
(existsSync(workPath) && !lstatSync(workPath).isSymbolicLink()));
|
|
7897
8119
|
// A live runtime cannot establish a stable final work inspection of itself,
|
|
7898
8120
|
// so self-retire never inspects, runs hooks, or removes anything here. It
|
|
7899
8121
|
// persists the intent and hands the whole retirement to a detached process
|
|
@@ -7905,7 +8127,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7905
8127
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
7906
8128
|
// the managed runtime; recovery copying never races a live managed Pi.
|
|
7907
8129
|
const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
|
|
7908
|
-
const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
|
|
8130
|
+
const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
|
|
7909
8131
|
// Runtime identity is destructive authority. The mutable child metadata may
|
|
7910
8132
|
// describe it for humans, but only the independent baseline can authorize the
|
|
7911
8133
|
// endpoint that proves quiescence.
|
|
@@ -7946,7 +8168,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7946
8168
|
if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${detail || "tmux inspection failed"}`);
|
|
7947
8169
|
}
|
|
7948
8170
|
}
|
|
7949
|
-
const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
|
|
8171
|
+
const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
|
|
7950
8172
|
const workRecoveries = [];
|
|
7951
8173
|
if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
|
|
7952
8174
|
let workRecovery = workRecoveries.at(-1);
|
|
@@ -7993,7 +8215,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7993
8215
|
|
|
7994
8216
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
7995
8217
|
// them and preserve a separately verified post-hook snapshot when needed.
|
|
7996
|
-
const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
|
|
8218
|
+
const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
|
|
7997
8219
|
if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
|
|
7998
8220
|
workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
|
|
7999
8221
|
workRecovery = workRecoveries.at(-1);
|
|
@@ -8164,6 +8386,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
8164
8386
|
const forced = !!(stillIncomplete && o.force);
|
|
8165
8387
|
if (!o.keepDir && (!stillIncomplete || forced)) {
|
|
8166
8388
|
rmSync(found.home, { recursive: true, force: true });
|
|
8389
|
+
rmSync(directoryFallbackPath, { force: true });
|
|
8167
8390
|
// The owed retirement is paid: clear the pending marker, and the failed
|
|
8168
8391
|
// outcome an earlier deferred attempt may have left beside the home; a
|
|
8169
8392
|
// deferred completion writes its own outcome after this returns.
|