@awebai/oats 0.22.1 → 0.22.2
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 +10 -3
- package/bin/oats.mjs +231 -16
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +3 -1
- package/capabilities/oats-okf/bin/oats-okf.mjs +125 -9
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +2 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/docs/execution-targets.md +181 -0
- package/docs/implementation.md +14 -1
- package/docs/migration-from-oas.md +1 -1
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/servers.md +94 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +379 -60
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +436 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/skills/oats/SKILL.md +6 -2
package/lib/core.mjs
CHANGED
|
@@ -23,17 +23,20 @@
|
|
|
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), model (pi model pattern, optional)
|
|
26
|
+
* work (worktree|checkout|attached), 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
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
29
|
+
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
30
30
|
import {
|
|
31
|
-
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
31
|
+
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
33
33
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
34
34
|
import { homedir, tmpdir } from "node:os";
|
|
35
35
|
import { createHash } from "node:crypto";
|
|
36
36
|
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
38
|
+
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
39
|
+
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget } from "./herdr.mjs";
|
|
37
40
|
|
|
38
41
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
39
42
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
@@ -426,7 +429,7 @@ export const RETIRED_CAPABILITIES = {
|
|
|
426
429
|
export function retiredCapabilityReason(id) {
|
|
427
430
|
return Object.hasOwn(RETIRED_CAPABILITIES, id) ? RETIRED_CAPABILITIES[id] : undefined;
|
|
428
431
|
}
|
|
429
|
-
const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates"]);
|
|
432
|
+
const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo"]);
|
|
430
433
|
/** Renamed-key tables are read with OWN-property semantics only: a config key
|
|
431
434
|
* spelled `constructor`/`toString` inherits a value from `Object.prototype`,
|
|
432
435
|
* and the plain `TABLE[key]` lookup then reported it as the migration hint —
|
|
@@ -467,6 +470,7 @@ function loadLevelConfig(dir) {
|
|
|
467
470
|
* Shared by the level loader and package profile validation (a profile is
|
|
468
471
|
* config source material and must pass the same shape checks). */
|
|
469
472
|
export function validateConfigShape(cfg, file) {
|
|
473
|
+
if (cfg.yolo !== undefined && typeof cfg.yolo !== "boolean") throw new Error(`yolo in ${file} must be true or false`);
|
|
470
474
|
for (const key of Object.keys(cfg)) {
|
|
471
475
|
if (Object.hasOwn(RENAMED_CONFIG_KEYS, key)) throw new Error(`unsupported oats-config key "${key}" in ${file} — ${RENAMED_CONFIG_KEYS[key]}`);
|
|
472
476
|
if (!CONFIG_KEYS.has(key)) throw new Error(`unsupported oats-config key in ${file}: ${key}`);
|
|
@@ -725,6 +729,8 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
725
729
|
export function resolveOatsConfig(contextDir, soulName) {
|
|
726
730
|
const chain = configChain(contextDir);
|
|
727
731
|
const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
|
|
732
|
+
const yoloCfg = chain.find((c) => c.yolo !== undefined);
|
|
733
|
+
if (yoloCfg) out.yolo = yoloCfg.yolo;
|
|
728
734
|
// Closest team: declaration wins; the declaring scope is the deployment/team boundary.
|
|
729
735
|
const teamCfg = chain.find((c) => c.team);
|
|
730
736
|
if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
|
|
@@ -4716,13 +4722,22 @@ function runSoulScaffoldHooks(args) {
|
|
|
4716
4722
|
if (Object.keys(owners).length) writeFileSync(ownersFile, JSON.stringify(owners, null, 2) + "\n");
|
|
4717
4723
|
}
|
|
4718
4724
|
|
|
4719
|
-
|
|
4725
|
+
/** Flat soul YAML uses strings; never treat the string "false" as truthy. */
|
|
4726
|
+
export function resolveYolo(value) {
|
|
4727
|
+
if (value === undefined) return undefined;
|
|
4728
|
+
if (value === true || value === "true") return true;
|
|
4729
|
+
if (value === false || value === "false") return false;
|
|
4730
|
+
throw new Error("yolo must be true or false");
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo, description, type, instructions }) {
|
|
4734
|
+
yolo = resolveYolo(yolo);
|
|
4720
4735
|
const agentDir = agentDirOf(root, name, kind);
|
|
4721
4736
|
const soulDir = soulOf(agentDir);
|
|
4722
4737
|
mkdirSync(soulDir, { recursive: true });
|
|
4723
4738
|
mkdirSync(join(agentDir, "instances"), { recursive: true });
|
|
4724
4739
|
writeFileSync(join(soulDir, "soul.yaml"), yamlFlat({
|
|
4725
|
-
name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model,
|
|
4740
|
+
name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model, yolo,
|
|
4726
4741
|
}));
|
|
4727
4742
|
const agentsMd = join(soulDir, "AGENTS.md");
|
|
4728
4743
|
if (instructions !== undefined || !existsSync(agentsMd)) {
|
|
@@ -4767,7 +4782,7 @@ export function createAgent(root, o) {
|
|
|
4767
4782
|
* Local souls are full souls — same scaffold and memory as persistent ones —
|
|
4768
4783
|
* that live in the scope's uncommitted local-agents/. */
|
|
4769
4784
|
export function upsertLocalAgent(root, o) {
|
|
4770
|
-
let { name, instructions, description, model, repo, work, runtime } = o;
|
|
4785
|
+
let { name, instructions, description, model, repo, work, runtime, yolo } = o;
|
|
4771
4786
|
if (o.file) {
|
|
4772
4787
|
const f = resolve(o.file);
|
|
4773
4788
|
if (!existsSync(f)) throw new Error(`file not found: ${f}`);
|
|
@@ -4778,6 +4793,7 @@ export function upsertLocalAgent(root, o) {
|
|
|
4778
4793
|
repo = repo ?? meta.repo;
|
|
4779
4794
|
work = work ?? meta.work;
|
|
4780
4795
|
runtime = runtime ?? meta.runtime;
|
|
4796
|
+
yolo = yolo ?? meta.yolo;
|
|
4781
4797
|
instructions = body;
|
|
4782
4798
|
}
|
|
4783
4799
|
if (!name) throw new Error("local agent requires a name");
|
|
@@ -4789,7 +4805,7 @@ export function upsertLocalAgent(root, o) {
|
|
|
4789
4805
|
writeSoul(root, {
|
|
4790
4806
|
name, kind: "local",
|
|
4791
4807
|
repo: repo ?? existing?.repo, work: work ?? existing?.work,
|
|
4792
|
-
runtime: runtime ?? existing?.runtime, model: model ?? existing?.model,
|
|
4808
|
+
runtime: runtime ?? existing?.runtime, model: model ?? existing?.model, yolo: yolo ?? existing?.yolo,
|
|
4793
4809
|
description: description ?? existing?.description, instructions,
|
|
4794
4810
|
});
|
|
4795
4811
|
return findAgent(root, name);
|
|
@@ -4909,9 +4925,19 @@ export function resolveClaudeBinary(contextDir) {
|
|
|
4909
4925
|
* pi: checked against `pi --list-models <pattern>` (authenticated providers).
|
|
4910
4926
|
* claude: pi-style patterns are translated (anthropic/<id> → <id>) or dropped —
|
|
4911
4927
|
* claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
|
|
4912
|
-
*
|
|
4928
|
+
* codex: translate openai/openai-codex entries to native ids, otherwise use its default.
|
|
4929
|
+
* Probe failures: first entry wins (pi errors loudly at launch). */
|
|
4913
4930
|
export function resolveModelPreference(model, runtime = "pi") {
|
|
4914
4931
|
const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
4932
|
+
if (runtime === "codex") {
|
|
4933
|
+
for (const pref of prefs) {
|
|
4934
|
+
const bare = pref.replace(/:[a-z]+$/i, "");
|
|
4935
|
+
if (!bare.includes("/")) return bare;
|
|
4936
|
+
const [provider, ...rest] = bare.split("/");
|
|
4937
|
+
if (["openai", "openai-codex"].includes(provider) && rest.length) return rest.join("/");
|
|
4938
|
+
}
|
|
4939
|
+
return ""; // let Codex use its configured model rather than another provider's id
|
|
4940
|
+
}
|
|
4915
4941
|
if (runtime === "claude") {
|
|
4916
4942
|
// Claude accepts its aliases and bare claude-* ids — NOT pi-style
|
|
4917
4943
|
// "provider/model[:thinking]" patterns. Agents whose soul default is a
|
|
@@ -5017,8 +5043,12 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5017
5043
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
5018
5044
|
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`);
|
|
5019
5045
|
const runtime = o.runtime || agent.runtime || "pi";
|
|
5046
|
+
if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
|
|
5020
5047
|
const model = resolveModelPreference(o.model || agent.model || "", runtime);
|
|
5021
5048
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
5049
|
+
const backend = o.backend || agent.backend || "tmux";
|
|
5050
|
+
if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
|
|
5051
|
+
if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
|
|
5022
5052
|
const launch = o.launch !== false;
|
|
5023
5053
|
const repoAbs = resolveRepo(root, o.repo || agent.repo);
|
|
5024
5054
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
@@ -5324,6 +5354,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5324
5354
|
const soulDir = agent._soulDir || soulOf(agent._dir);
|
|
5325
5355
|
const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
|
|
5326
5356
|
const resolvedCfg = composition.resolved;
|
|
5357
|
+
const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
|
|
5327
5358
|
const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
|
|
5328
5359
|
// Runtime extensions selected by ACTIVE capabilities for THIS instance's
|
|
5329
5360
|
// runtime. Strict launch disables ambient extension discovery, so each one has
|
|
@@ -5335,9 +5366,10 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5335
5366
|
|
|
5336
5367
|
// Prerequisites must fail before creating a home, worktree, or identity.
|
|
5337
5368
|
const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
|
|
5338
|
-
const bin = which(runtime === "claude" ? claudeBin :
|
|
5369
|
+
const bin = which(runtime === "claude" ? claudeBin : runtime);
|
|
5339
5370
|
if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
|
|
5340
|
-
if (launch && !which(
|
|
5371
|
+
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
5372
|
+
const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
5341
5373
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5342
5374
|
|
|
5343
5375
|
mkdirSync(home, { recursive: true });
|
|
@@ -5524,6 +5556,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5524
5556
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
5525
5557
|
let windowMayExist = false;
|
|
5526
5558
|
let spawnTmux;
|
|
5559
|
+
let spawnHerdr;
|
|
5527
5560
|
const ancillaryCleanup = [];
|
|
5528
5561
|
// One compensation owner, from the first hook result through launch and
|
|
5529
5562
|
// the final lineage write. Preserve the original failure and retain any
|
|
@@ -5543,7 +5576,23 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5543
5576
|
const outstandingGit = new Set();
|
|
5544
5577
|
// A failed new-window command may still have created its window. Verify
|
|
5545
5578
|
// quiescence before removing credentials or work that runtime may be using.
|
|
5546
|
-
if (windowMayExist) {
|
|
5579
|
+
if (windowMayExist && spawnHerdr) {
|
|
5580
|
+
try { stopHerdr(spawnHerdr); }
|
|
5581
|
+
catch (e) {
|
|
5582
|
+
incomplete.push(`Herdr session: ${e.message}`);
|
|
5583
|
+
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5584
|
+
if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
|
|
5585
|
+
return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
5586
|
+
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
5587
|
+
launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
if (windowMayExist && backend === "herdr" && !spawnHerdr) {
|
|
5591
|
+
// Allocation starts only an empty shell; the harness command has not run.
|
|
5592
|
+
// A lost receipt cannot authorize closing an unidentified terminal.
|
|
5593
|
+
incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
|
|
5594
|
+
}
|
|
5595
|
+
if (windowMayExist && backend === "tmux") {
|
|
5547
5596
|
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
5548
5597
|
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
5549
5598
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
@@ -5655,7 +5704,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5655
5704
|
You are instance "${instance}" of agent "${agent.name}".
|
|
5656
5705
|
- Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
|
|
5657
5706
|
- Work tree: ./work — ${workDesc}
|
|
5658
|
-
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
|
|
5707
|
+
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${runtime === "codex" ? "\n## Runtime notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
|
|
5659
5708
|
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
|
|
5660
5709
|
|
|
5661
5710
|
// Launch command. Spawn IS session start: this command is persisted in
|
|
@@ -5672,7 +5721,18 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5672
5721
|
// the TASK.md text is swallowed as that flag's next value — claude
|
|
5673
5722
|
// errors out ("entries must be tagged: <task text>") and the window
|
|
5674
5723
|
// drops to the fallback shell, which reads as a silently stuck spawn.
|
|
5675
|
-
cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5724
|
+
cmdline = `${shq(bin)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5725
|
+
} else if (runtime === "codex") {
|
|
5726
|
+
// Codex discovers this home's AGENTS.md and .agents/skills natively.
|
|
5727
|
+
// Keep the operator's native permission policy. --add-dir is not valid
|
|
5728
|
+
// under every policy (including untrusted/read-only startup). Worktrees
|
|
5729
|
+
// already live below home; external paths use native approval handling.
|
|
5730
|
+
// --yolo bypasses execution approvals but Codex still asks to trust a new
|
|
5731
|
+
// project. The operator's explicit yolo choice also trusts this generated
|
|
5732
|
+
// home for this launch, without editing the shared user config.
|
|
5733
|
+
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
5734
|
+
cmdline = `${shq(bin)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}`
|
|
5735
|
+
+ `${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5676
5736
|
} else {
|
|
5677
5737
|
// STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
|
|
5678
5738
|
// or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
|
|
@@ -5729,6 +5789,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5729
5789
|
const meta = {
|
|
5730
5790
|
agent: agent.name, kind: agent.kind || "persistent", instance, home,
|
|
5731
5791
|
repo: repoAbs, work, branch, runtime, model: model || undefined,
|
|
5792
|
+
...(yolo !== undefined ? { yolo } : {}),
|
|
5732
5793
|
team: resolvedCfg.team || undefined,
|
|
5733
5794
|
parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
|
|
5734
5795
|
siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
|
|
@@ -5768,7 +5829,11 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5768
5829
|
ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
|
|
5769
5830
|
why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
|
|
5770
5831
|
}
|
|
5771
|
-
: {
|
|
5832
|
+
: runtime === "codex" ? {
|
|
5833
|
+
oatsComposed: "skills via .agents/skills; instructions via AGENTS.md; task via initial prompt",
|
|
5834
|
+
ambient: ["user and ancestor instructions", "user, project, admin and system skills", "user and project configuration and MCP servers"],
|
|
5835
|
+
why: yolo ? "Codex keeps native configuration with approval prompts and sandbox disabled by the OATS yolo setting." : "Codex keeps the operator's native configuration and approval policy, including approval handling for work paths outside the home.",
|
|
5836
|
+
} : {
|
|
5772
5837
|
oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
|
|
5773
5838
|
curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
|
|
5774
5839
|
ambient: ["globally configured pi extensions, and any resources they contribute"],
|
|
@@ -5784,13 +5849,21 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5784
5849
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
5785
5850
|
executable: cap.executable,
|
|
5786
5851
|
})),
|
|
5787
|
-
tmux: { session, window: instance },
|
|
5852
|
+
...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
|
|
5788
5853
|
command: cmdline, createdAt: new Date().toISOString(),
|
|
5789
5854
|
};
|
|
5790
5855
|
const spawnWarnings = warnings;
|
|
5791
5856
|
|
|
5792
5857
|
spawnTmux = meta.tmux;
|
|
5793
|
-
if (launch) {
|
|
5858
|
+
if (launch && backend === "herdr") {
|
|
5859
|
+
windowMayExist = true;
|
|
5860
|
+
spawnHerdr = allocateHerdr(herdrBase, { home, instance });
|
|
5861
|
+
meta.sessionTarget = spawnHerdr;
|
|
5862
|
+
meta.launched = true;
|
|
5863
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5864
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
|
|
5865
|
+
launchHerdr(spawnHerdr, cmdline);
|
|
5866
|
+
} else if (launch) {
|
|
5794
5867
|
if (!tmuxAlive(session)) {
|
|
5795
5868
|
const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
|
|
5796
5869
|
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
@@ -5844,7 +5917,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5844
5917
|
}
|
|
5845
5918
|
}
|
|
5846
5919
|
|
|
5847
|
-
return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
5920
|
+
return { ...meta, attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
5848
5921
|
} catch (error) {
|
|
5849
5922
|
const note = compensateSpawn();
|
|
5850
5923
|
error.message += note;
|
|
@@ -5856,8 +5929,11 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5856
5929
|
const windows = tmuxWindows(tmuxSession);
|
|
5857
5930
|
const readInstancesOf = (agentDir) => {
|
|
5858
5931
|
const instancesDir = join(agentDir, "instances");
|
|
5932
|
+
// An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
|
|
5933
|
+
// dot-directory under instances/ is kernel bookkeeping (.oats-retirement
|
|
5934
|
+
// holds baselines and recoveries), never a home (oats-5xl).
|
|
5859
5935
|
return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
|
|
5860
|
-
.filter((e) => e.isDirectory())
|
|
5936
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
5861
5937
|
.map((e) => {
|
|
5862
5938
|
const metaPath = join(instancesDir, e.name, "instance.json");
|
|
5863
5939
|
const home = join(instancesDir, e.name);
|
|
@@ -5872,12 +5948,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5872
5948
|
try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
|
|
5873
5949
|
catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
|
|
5874
5950
|
}
|
|
5875
|
-
|
|
5951
|
+
// A self-retire that has been requested but not completed: the home is
|
|
5952
|
+
// owed a retirement, not live work. Read-only here — completion is the
|
|
5953
|
+
// detached child's or an operator's `oats retire`, never status.
|
|
5954
|
+
const pending = retirePendingMarkerPath(home);
|
|
5955
|
+
let retirePending;
|
|
5956
|
+
if (existsSync(pending)) {
|
|
5957
|
+
try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
|
|
5958
|
+
catch { retirePending = { reason: "retire pending" }; }
|
|
5959
|
+
}
|
|
5960
|
+
let liveness = { running: windows.includes(meta.instance || e.name) };
|
|
5961
|
+
if (meta.sessionTarget) {
|
|
5962
|
+
try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
|
|
5963
|
+
catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
|
|
5964
|
+
}
|
|
5965
|
+
return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
|
|
5966
|
+
|
|
5876
5967
|
});
|
|
5877
5968
|
};
|
|
5969
|
+
// Failed deferred self-retirements leave their outcome beside the home; a
|
|
5970
|
+
// successful one is evidence only and is not a problem to surface.
|
|
5971
|
+
const readRetireFailuresOf = (agentDir) => {
|
|
5972
|
+
const instancesDir = join(agentDir, "instances");
|
|
5973
|
+
if (!existsSync(instancesDir)) return [];
|
|
5974
|
+
const out = [];
|
|
5975
|
+
for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
|
|
5976
|
+
const m = e.isFile() && /^\.oats-retired-(.+)\.json$/.exec(e.name);
|
|
5977
|
+
if (!m) continue;
|
|
5978
|
+
try {
|
|
5979
|
+
const r = JSON.parse(readFileSync(join(instancesDir, e.name), "utf8"));
|
|
5980
|
+
if (r.ok === false) out.push({ instance: m[1], completedAt: r.completedAt, error: r.error?.message, incomplete: r.result?.rollbackIncomplete, retry: r.retry, resultPath: join(instancesDir, e.name) });
|
|
5981
|
+
} catch { out.push({ instance: m[1], error: "unreadable result file", resultPath: join(instancesDir, e.name) }); }
|
|
5982
|
+
}
|
|
5983
|
+
return out;
|
|
5984
|
+
};
|
|
5985
|
+
const withFailures = (entry, agentDir) => {
|
|
5986
|
+
const retireFailures = readRetireFailuresOf(agentDir);
|
|
5987
|
+
return retireFailures.length ? { ...entry, retireFailures } : entry;
|
|
5988
|
+
};
|
|
5878
5989
|
const out = listAgents(root).map((a) => {
|
|
5879
5990
|
const { _dir, ...soul } = a;
|
|
5880
|
-
return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
|
|
5991
|
+
return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
|
|
5881
5992
|
});
|
|
5882
5993
|
// Capability-defined agents home under local-agents/<name>/ WITHOUT a local
|
|
5883
5994
|
// soul (it lives read-only in the package) — surface their instances too.
|
|
@@ -5887,9 +5998,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5887
5998
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
5888
5999
|
if (!e.isDirectory() || seen.has(e.name)) continue;
|
|
5889
6000
|
const instances = readInstancesOf(join(dir, e.name));
|
|
5890
|
-
|
|
6001
|
+
const retireFailures = readRetireFailuresOf(join(dir, e.name));
|
|
6002
|
+
if (!instances.length && !retireFailures.length) continue;
|
|
5891
6003
|
const cap = instances.find((i) => i.capability)?.capability;
|
|
5892
|
-
out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances });
|
|
6004
|
+
out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances, ...(retireFailures.length ? { retireFailures } : {}) });
|
|
5893
6005
|
seen.add(e.name);
|
|
5894
6006
|
}
|
|
5895
6007
|
}
|
|
@@ -5967,7 +6079,7 @@ export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
|
|
|
5967
6079
|
|
|
5968
6080
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
5969
6081
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
5970
|
-
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
|
|
6082
|
+
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
|
|
5971
6083
|
try {
|
|
5972
6084
|
writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
|
|
5973
6085
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
@@ -5983,6 +6095,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
5983
6095
|
cleanup: {
|
|
5984
6096
|
version: QUARANTINE_CLEANUP_VERSION,
|
|
5985
6097
|
repo: repoAbs, work, branch, launched, tmux,
|
|
6098
|
+
...(sessionTarget ? { sessionTarget } : {}),
|
|
5986
6099
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
|
|
5987
6100
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
5988
6101
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
@@ -5997,7 +6110,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
5997
6110
|
} catch { /* the quarantine still stands without its marker */ }
|
|
5998
6111
|
if (recordRetirementBaseline) {
|
|
5999
6112
|
try {
|
|
6000
|
-
writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux });
|
|
6113
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
|
|
6001
6114
|
} catch (e) {
|
|
6002
6115
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
6003
6116
|
}
|
|
@@ -6071,6 +6184,15 @@ function retirementBaselinePath(home) {
|
|
|
6071
6184
|
return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
|
|
6072
6185
|
}
|
|
6073
6186
|
|
|
6187
|
+
/** git's output for a large tree (status with untracked and ignored files,
|
|
6188
|
+
* the index listing, a large blob) easily exceeds Node's 1 MiB default child
|
|
6189
|
+
* buffer; the spawn then dies with ENOBUFS and retirement reports the
|
|
6190
|
+
* recovery as unverifiable (cjr, ~9500 tracked long paths). Every git call
|
|
6191
|
+
* on the retirement path gets this bound instead. It raises the practical
|
|
6192
|
+
* limit, it does not remove it: a single blob over 512 MiB would still fail,
|
|
6193
|
+
* loudly, and streaming is deliberately not attempted in this change. */
|
|
6194
|
+
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
6195
|
+
|
|
6074
6196
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
6075
6197
|
const hash = createHash("sha256");
|
|
6076
6198
|
const rootStat = lstatSync(root);
|
|
@@ -6100,7 +6222,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
6100
6222
|
|
|
6101
6223
|
function worktreeStatus(repo) {
|
|
6102
6224
|
try {
|
|
6103
|
-
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
6225
|
+
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6104
6226
|
} catch (e) {
|
|
6105
6227
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
|
|
6106
6228
|
}
|
|
@@ -6151,6 +6273,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
|
|
|
6151
6273
|
runtime: {
|
|
6152
6274
|
launched: runtime?.launched === true,
|
|
6153
6275
|
...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
|
|
6276
|
+
...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
|
|
6154
6277
|
},
|
|
6155
6278
|
};
|
|
6156
6279
|
const path = retirementBaselinePath(home);
|
|
@@ -6176,7 +6299,7 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6176
6299
|
if (!repo || !branch) return [];
|
|
6177
6300
|
const target = `refs/heads/${branch}`;
|
|
6178
6301
|
try {
|
|
6179
|
-
execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6302
|
+
execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6180
6303
|
} catch (e) {
|
|
6181
6304
|
const detail = String(e.stderr ?? "").trim();
|
|
6182
6305
|
// A quarantine retry may follow a successful rollback-owned branch removal.
|
|
@@ -6185,9 +6308,9 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6185
6308
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6186
6309
|
}
|
|
6187
6310
|
try {
|
|
6188
|
-
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
6311
|
+
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
|
|
6189
6312
|
.split("\n").filter((ref) => ref && ref !== target);
|
|
6190
|
-
return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").filter(Boolean);
|
|
6313
|
+
return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim().split("\n").filter(Boolean);
|
|
6191
6314
|
} catch (e) {
|
|
6192
6315
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6193
6316
|
}
|
|
@@ -6197,11 +6320,55 @@ function runtimeAuthorityOf(baseline) {
|
|
|
6197
6320
|
const runtime = baseline?.runtime;
|
|
6198
6321
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
6199
6322
|
if (!runtime.launched) return { launched: false };
|
|
6323
|
+
if (runtime.sessionTarget !== undefined) {
|
|
6324
|
+
if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
|
|
6325
|
+
return { launched: true, sessionTarget: runtime.sessionTarget };
|
|
6326
|
+
}
|
|
6200
6327
|
const tmux = runtime.tmux;
|
|
6201
6328
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
6202
6329
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
6203
6330
|
}
|
|
6204
6331
|
|
|
6332
|
+
/** Session control uses the same independent endpoint receipt as retirement. */
|
|
6333
|
+
function instanceSessionTarget(home) {
|
|
6334
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
6335
|
+
home = realPathOrNearest(home);
|
|
6336
|
+
let baseline, meta;
|
|
6337
|
+
try {
|
|
6338
|
+
baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
|
|
6339
|
+
meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
|
|
6340
|
+
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
6341
|
+
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
6342
|
+
if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
|
|
6343
|
+
const endpointAgrees = authority.sessionTarget
|
|
6344
|
+
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
|
|
6345
|
+
: !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
6346
|
+
if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
6347
|
+
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
|
|
6348
|
+
}
|
|
6349
|
+
|
|
6350
|
+
export function inspectInstanceSession(home) {
|
|
6351
|
+
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
|
|
6352
|
+
const s = instanceSessionTarget(home);
|
|
6353
|
+
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
|
|
6354
|
+
try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
|
|
6355
|
+
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6358
|
+
export async function attachInstanceSession(home) {
|
|
6359
|
+
const s = instanceSessionTarget(home);
|
|
6360
|
+
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
6361
|
+
return attachSessionTarget(s.target);
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
export function inputInstanceSession(home, text) {
|
|
6365
|
+
if (typeof text !== "string" || !text.trim() || text.includes("\0") || Buffer.byteLength(text) > 256 * 1024) throw oatsError("E_BAD_ARGS", "session input must be nonempty text without NUL, at most 256 KiB");
|
|
6366
|
+
const s = instanceSessionTarget(home);
|
|
6367
|
+
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
6368
|
+
try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
|
|
6369
|
+
catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
|
|
6370
|
+
}
|
|
6371
|
+
|
|
6205
6372
|
function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
|
|
6206
6373
|
const classes = [];
|
|
6207
6374
|
let baseline;
|
|
@@ -6248,14 +6415,14 @@ const RECOVERABLE_GIT_ADMIN = [
|
|
|
6248
6415
|
];
|
|
6249
6416
|
|
|
6250
6417
|
function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
6251
|
-
const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim();
|
|
6418
|
+
const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6252
6419
|
const recoveredGit = join(recoveredRepo, ".git");
|
|
6253
|
-
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"]);
|
|
6420
|
+
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
|
|
6254
6421
|
for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
|
|
6255
6422
|
const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
|
|
6256
6423
|
if (!match) continue;
|
|
6257
|
-
const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]]);
|
|
6258
|
-
const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" }).trim();
|
|
6424
|
+
const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]], { maxBuffer: GIT_MAX_BUFFER });
|
|
6425
|
+
const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6259
6426
|
if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
|
|
6260
6427
|
}
|
|
6261
6428
|
copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
|
|
@@ -6270,11 +6437,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
|
6270
6437
|
|
|
6271
6438
|
function detachRecoveryClone(source, recovered) {
|
|
6272
6439
|
let stash;
|
|
6273
|
-
try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
6440
|
+
try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
|
|
6274
6441
|
catch { stash = undefined; }
|
|
6275
6442
|
if (stash) {
|
|
6276
|
-
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"]);
|
|
6277
|
-
const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim();
|
|
6443
|
+
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
|
|
6444
|
+
const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6278
6445
|
const stashLog = join(sourceCommon, "logs", "refs", "stash");
|
|
6279
6446
|
if (existsSync(stashLog)) {
|
|
6280
6447
|
const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
|
|
@@ -6282,17 +6449,17 @@ function detachRecoveryClone(source, recovered) {
|
|
|
6282
6449
|
copyFileSync(stashLog, recoveredLog);
|
|
6283
6450
|
}
|
|
6284
6451
|
}
|
|
6285
|
-
try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" }); } catch { /* no remote is already independent */ }
|
|
6452
|
+
try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" , maxBuffer: GIT_MAX_BUFFER }); } catch { /* no remote is already independent */ }
|
|
6286
6453
|
}
|
|
6287
6454
|
|
|
6288
6455
|
function materializeNestedRepositories(sourceWork, recoveredRepo) {
|
|
6289
6456
|
for (const source of nestedGitRoots(sourceWork)) {
|
|
6290
6457
|
const rel = relative(sourceWork, source);
|
|
6291
6458
|
const dest = join(recoveredRepo, rel);
|
|
6292
|
-
const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
6459
|
+
const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6293
6460
|
rmSync(dest, { recursive: true, force: true });
|
|
6294
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6295
|
-
execFileSync("git", ["-C", dest, "checkout", "--quiet", head]);
|
|
6461
|
+
execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6462
|
+
execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
|
|
6296
6463
|
detachRecoveryClone(source, dest);
|
|
6297
6464
|
restoreStandaloneGitState(source, dest);
|
|
6298
6465
|
for (const e of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -6319,7 +6486,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6319
6486
|
}
|
|
6320
6487
|
if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6321
6488
|
const recoveredRepo = join(staging, "repo");
|
|
6322
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6489
|
+
execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6323
6490
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
6324
6491
|
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
6325
6492
|
if (existsSync(observation.work)) {
|
|
@@ -6337,8 +6504,8 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6337
6504
|
if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
|
|
6338
6505
|
if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
|
|
6339
6506
|
}
|
|
6340
|
-
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
6341
|
-
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" }).trim();
|
|
6507
|
+
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6508
|
+
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6342
6509
|
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
6343
6510
|
}
|
|
6344
6511
|
writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
|
|
@@ -6351,9 +6518,143 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6351
6518
|
}
|
|
6352
6519
|
}
|
|
6353
6520
|
|
|
6521
|
+
/** Marker a self-retiring instance leaves BESIDE its home: the retirement is
|
|
6522
|
+
* requested and owed, and a detached completion is on its way. It is not
|
|
6523
|
+
* written into the home, so the caller changes no instance bytes and the
|
|
6524
|
+
* completion's work inspection sees exactly what the instance left. */
|
|
6525
|
+
export function retirePendingMarkerPath(home) {
|
|
6526
|
+
return join(dirname(home), `.oats-retire-pending-${basename(home)}.json`);
|
|
6527
|
+
}
|
|
6528
|
+
|
|
6529
|
+
/** Where a deferred self-retirement writes its explicit outcome: a FILE beside
|
|
6530
|
+
* the (former) home, so it survives the home's removal and never reads as an
|
|
6531
|
+
* instance directory. */
|
|
6532
|
+
export function deferredRetireResultPath(home) {
|
|
6533
|
+
return join(dirname(home), `.oats-retired-${basename(home)}.json`);
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6536
|
+
const DEFERRED_RETIRE_SCRIPT = `import { completeDeferredRetirement } from ${JSON.stringify(import.meta.url)};
|
|
6537
|
+
process.exitCode = completeDeferredRetirement(JSON.parse(process.env.OATS_RETIRE_INTENT)) ? 0 : 1;`;
|
|
6538
|
+
|
|
6539
|
+
/** Self-retire (aweb-abep): persist intent, then hand the retirement to a
|
|
6540
|
+
* detached process that runs it as an ORDINARY external retirement after the
|
|
6541
|
+
* caller's window has died. Nothing destructive happens in the caller: no
|
|
6542
|
+
* inspection, no hooks, no removal — the runtime is still alive, and the
|
|
6543
|
+
* quiesce rule stays intact. The child owns its own process group so the
|
|
6544
|
+
* tmux window kill (SIGHUP to the pane's group) cannot take it down, and the
|
|
6545
|
+
* caller's instance env is stripped so the child is an external operator,
|
|
6546
|
+
* not another self-retire. The intent travels to the child in its env; the
|
|
6547
|
+
* marker beside the home is the operator-visible promise and is written only
|
|
6548
|
+
* once a completion process exists (reviewer D2). */
|
|
6549
|
+
function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
6550
|
+
const delaySec = o.selfKillDelaySec ?? 8;
|
|
6551
|
+
const marker = retirePendingMarkerPath(found.home);
|
|
6552
|
+
const resultPath = deferredRetireResultPath(found.home);
|
|
6553
|
+
const logPath = resultPath.replace(/\.json$/, ".log");
|
|
6554
|
+
// A second `--self` while the first completion is still on its way must not
|
|
6555
|
+
// start a second, racing retirement: report the one already owed.
|
|
6556
|
+
if (existsSync(marker) && !existsSync(resultPath)) {
|
|
6557
|
+
let prior; try { prior = JSON.parse(readFileSync(marker, "utf8")); } catch { prior = undefined; }
|
|
6558
|
+
if (prior?.resultPath) {
|
|
6559
|
+
return { retired: name, agent: found.agent.name, deferred: true, alreadyScheduled: true, pendingMarker: marker, resultPath: prior.resultPath, logPath, completesInSec: prior.delaySec ?? delaySec, requestedAt: prior.requestedAt };
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
const intent = {
|
|
6563
|
+
instance: name, agent: found.agent.name, root: resolve(root),
|
|
6564
|
+
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
6565
|
+
options: { deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
6566
|
+
};
|
|
6567
|
+
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
6568
|
+
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
6569
|
+
rmSync(resultPath, { force: true });
|
|
6570
|
+
const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name}\``);
|
|
6571
|
+
let fd, child;
|
|
6572
|
+
try {
|
|
6573
|
+
fd = openSync(logPath, "w");
|
|
6574
|
+
child = spawnProcess(process.execPath, ["--input-type=module", "-e", DEFERRED_RETIRE_SCRIPT], {
|
|
6575
|
+
detached: true, stdio: ["ignore", fd, fd], env, cwd: dirname(found.home),
|
|
6576
|
+
});
|
|
6577
|
+
} catch (e) {
|
|
6578
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
6579
|
+
rmSync(logPath, { force: true });
|
|
6580
|
+
throw scheduleFailed(e.message);
|
|
6581
|
+
}
|
|
6582
|
+
closeSync(fd);
|
|
6583
|
+
if (!child.pid) { rmSync(logPath, { force: true }); throw scheduleFailed("no process was created"); }
|
|
6584
|
+
// A spawn failure Node reports asynchronously (EAGAIN, EMFILE) would arrive
|
|
6585
|
+
// after this returns; record it as a failed outcome so status shows the
|
|
6586
|
+
// debt instead of an uncaught exception behind a success message.
|
|
6587
|
+
child.on("error", (e) => {
|
|
6588
|
+
try {
|
|
6589
|
+
writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name}` }, null, 2) + "\n");
|
|
6590
|
+
} catch { /* the marker alone then shows RETIRING; status names its age */ }
|
|
6591
|
+
});
|
|
6592
|
+
child.unref();
|
|
6593
|
+
writeFileSync(marker, JSON.stringify(intent, null, 2) + "\n");
|
|
6594
|
+
return {
|
|
6595
|
+
retired: name, agent: found.agent.name, deferred: true, pendingMarker: marker,
|
|
6596
|
+
resultPath, logPath, completesInSec: delaySec, completionPid: child.pid,
|
|
6597
|
+
};
|
|
6598
|
+
}
|
|
6599
|
+
|
|
6600
|
+
/** Run by the detached child: wait for the caller's window to be gone, then
|
|
6601
|
+
* retire the instance as an ordinary external operator. Returns true only
|
|
6602
|
+
* when the home is actually gone, and then leaves no file behind. A failure
|
|
6603
|
+
* writes an explicit outcome beside the home and leaves the pending marker
|
|
6604
|
+
* (and, after hooks ran, the usual quarantine) in place, so `oats status`
|
|
6605
|
+
* shows the debt and `oats retire <name>` retries and clears it. Accepts the
|
|
6606
|
+
* intent object (the child gets it in its env) or a marker path. */
|
|
6607
|
+
export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
6608
|
+
let intent = intentOrMarkerPath;
|
|
6609
|
+
if (typeof intentOrMarkerPath === "string") {
|
|
6610
|
+
try { intent = JSON.parse(readFileSync(intentOrMarkerPath, "utf8")); }
|
|
6611
|
+
catch (e) { console.error(`deferred retirement: cannot read ${intentOrMarkerPath}: ${e.message}`); return false; }
|
|
6612
|
+
}
|
|
6613
|
+
if (!isPlainObject(intent) || !intent.instance || !intent.root) { console.error("deferred retirement: intent is not usable"); return false; }
|
|
6614
|
+
const record = (payload) => {
|
|
6615
|
+
if (!intent.resultPath) return;
|
|
6616
|
+
try {
|
|
6617
|
+
writeFileSync(intent.resultPath, JSON.stringify({
|
|
6618
|
+
instance: intent.instance, agent: intent.agent, requestedAt: intent.requestedAt,
|
|
6619
|
+
completedAt: new Date().toISOString(), ...payload,
|
|
6620
|
+
}, null, 2) + "\n");
|
|
6621
|
+
} catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
|
|
6622
|
+
};
|
|
6623
|
+
const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
|
|
6624
|
+
if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
|
6625
|
+
let result;
|
|
6626
|
+
try {
|
|
6627
|
+
result = retireInstance(intent.root, intent.instance, {
|
|
6628
|
+
deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6629
|
+
});
|
|
6630
|
+
} catch (e) {
|
|
6631
|
+
record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}` });
|
|
6632
|
+
console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
|
|
6633
|
+
return false;
|
|
6634
|
+
}
|
|
6635
|
+
if (result.rollbackIncomplete) {
|
|
6636
|
+
record({ ok: false, result, retry: `oats retire ${intent.instance}` });
|
|
6637
|
+
console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
|
|
6638
|
+
return false;
|
|
6639
|
+
}
|
|
6640
|
+
if (intent.options?.keepDir) {
|
|
6641
|
+
const retained = findInstanceHome(intent.root, intent.instance);
|
|
6642
|
+
if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
|
|
6643
|
+
}
|
|
6644
|
+
// Success leaves nothing beside the home: the home is gone, the marker went
|
|
6645
|
+
// with it, and a kept success record per retired reviewer or harvester would
|
|
6646
|
+
// accumulate forever (reviewer D4). Only failures leave files, and they are
|
|
6647
|
+
// the ones status surfaces and a retry clears.
|
|
6648
|
+
try { rmSync(intent.resultPath.replace(/\.json$/, ".log"), { force: true }); } catch { /* nothing to keep */ }
|
|
6649
|
+
return true;
|
|
6650
|
+
}
|
|
6651
|
+
|
|
6354
6652
|
export function retireInstance(root, name, o = {}) {
|
|
6355
6653
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
6356
|
-
|
|
6654
|
+
// self-retire: the caller IS the instance. Without --keep-dir the whole
|
|
6655
|
+
// retirement is deferred to a detached external completion (below); with
|
|
6656
|
+
// --keep-dir the old in-process path runs and kills the window LAST.
|
|
6657
|
+
const self = o.self === true;
|
|
6357
6658
|
const found = findInstanceHome(root, name);
|
|
6358
6659
|
if (!found) throw new Error(`no instance named "${name}"`);
|
|
6359
6660
|
const metaPath = join(found.home, "instance.json");
|
|
@@ -6390,6 +6691,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6390
6691
|
if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
|
|
6391
6692
|
meta.launched = quarantine.cleanup.launched;
|
|
6392
6693
|
meta.tmux = quarantine.cleanup.tmux;
|
|
6694
|
+
meta.sessionTarget = quarantine.cleanup.sessionTarget;
|
|
6393
6695
|
}
|
|
6394
6696
|
// A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
|
|
6395
6697
|
// retired safely: hooks would be skipped and the directory removed, which is
|
|
@@ -6409,8 +6711,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6409
6711
|
const workPath = join(found.home, "work");
|
|
6410
6712
|
const isWorktree = meta.work === "worktree" ||
|
|
6411
6713
|
(existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
|
|
6412
|
-
|
|
6413
|
-
|
|
6714
|
+
// A live runtime cannot establish a stable final work inspection of itself,
|
|
6715
|
+
// so self-retire never inspects, runs hooks, or removes anything here. It
|
|
6716
|
+
// persists the intent and hands the whole retirement to a detached process
|
|
6717
|
+
// that runs it as an ordinary EXTERNAL retirement once the runtime is gone
|
|
6718
|
+
// (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
|
|
6719
|
+
if (self && (!o.keepDir || meta.sessionTarget)) {
|
|
6720
|
+
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
6414
6721
|
}
|
|
6415
6722
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
6416
6723
|
// the managed runtime; recovery copying never races a live managed Pi.
|
|
@@ -6425,11 +6732,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6425
6732
|
if (!runtimeAuthority) {
|
|
6426
6733
|
throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
|
|
6427
6734
|
}
|
|
6428
|
-
const
|
|
6429
|
-
meta.tmux?.
|
|
6430
|
-
meta.tmux?.
|
|
6431
|
-
|
|
6432
|
-
|
|
6735
|
+
const endpointAgrees = runtimeAuthority.sessionTarget
|
|
6736
|
+
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
|
|
6737
|
+
: !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
|
|
6738
|
+
&& meta.tmux?.window === runtimeAuthority.tmux?.window
|
|
6739
|
+
&& resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
|
|
6740
|
+
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
|
|
6433
6741
|
if (!metaAgrees) {
|
|
6434
6742
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
6435
6743
|
}
|
|
@@ -6437,7 +6745,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6437
6745
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
6438
6746
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
6439
6747
|
// window absence established before recovery copying begins.
|
|
6440
|
-
if (!self && runtimeAuthority?.launched) {
|
|
6748
|
+
if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
|
|
6749
|
+
try { stopHerdr(runtimeAuthority.sessionTarget); }
|
|
6750
|
+
catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
|
|
6751
|
+
}
|
|
6752
|
+
if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
|
|
6441
6753
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
6442
6754
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
6443
6755
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -6480,13 +6792,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6480
6792
|
// describes. A hook that reports nothing, or reports retired:true, is
|
|
6481
6793
|
// unaffected; only an explicit "I did not finish" changes the outcome.
|
|
6482
6794
|
let ordinaryIncomplete = [];
|
|
6483
|
-
//
|
|
6484
|
-
//
|
|
6485
|
-
//
|
|
6486
|
-
//
|
|
6487
|
-
//
|
|
6488
|
-
//
|
|
6489
|
-
// owner to reconcile.
|
|
6795
|
+
// The IN-PROCESS self path (--self --keep-dir only, since aweb-abep) is
|
|
6796
|
+
// excluded: that caller is the instance and cannot hold the authority to
|
|
6797
|
+
// complete owner cleanup (reviewer-5c8b724). A plain --self never reaches
|
|
6798
|
+
// here as `self`: its deferred completion calls retireInstance as an
|
|
6799
|
+
// external operator, so a self-retiring reviewer or harvester whose hook
|
|
6800
|
+
// reports incomplete cleanup IS quarantined like any other instance.
|
|
6490
6801
|
if (!quarantine && !self) {
|
|
6491
6802
|
for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
|
|
6492
6803
|
// A hook may exit 0 and still report it did not finish. Only an explicit
|
|
@@ -6669,7 +6980,15 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6669
6980
|
// unreachable remote) would be unremovable through OATS forever — the same
|
|
6670
6981
|
// dead end the unusable-marker fixes closed, just reached from a valid one.
|
|
6671
6982
|
const forced = !!(stillIncomplete && o.force);
|
|
6672
|
-
if (!o.keepDir && (!stillIncomplete || forced))
|
|
6983
|
+
if (!o.keepDir && (!stillIncomplete || forced)) {
|
|
6984
|
+
rmSync(found.home, { recursive: true, force: true });
|
|
6985
|
+
// The owed retirement is paid: clear the pending marker, and the failed
|
|
6986
|
+
// outcome an earlier deferred attempt may have left beside the home; a
|
|
6987
|
+
// deferred completion writes its own outcome after this returns.
|
|
6988
|
+
rmSync(retirePendingMarkerPath(found.home), { force: true });
|
|
6989
|
+
rmSync(deferredRetireResultPath(found.home), { force: true });
|
|
6990
|
+
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6991
|
+
}
|
|
6673
6992
|
|
|
6674
6993
|
|
|
6675
6994
|
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, harvested, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
|