@awebai/oats 0.22.0 → 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 +40 -50
- package/bin/oats.mjs +242 -22
- package/capabilities/oats-authoring/LICENSE +21 -0
- package/capabilities/oats-authoring/oats-package.json +11 -0
- package/capabilities/oats-authoring/oats.json +4 -4
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +7 -7
- package/capabilities/oats-aweb/skills/LICENSE +21 -0
- package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
- package/capabilities/oats-jira/oats.json +1 -1
- package/capabilities/oats-linear/oats.json +1 -1
- package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +5 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/capabilities/oats-review/oats.json +1 -1
- package/docs/2026-09-03-architecture-proposal.md +642 -0
- package/docs/execution-targets.md +181 -0
- package/docs/first-team-demo.md +87 -0
- package/docs/first-team.md +179 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +83 -65
- package/docs/layers.md +356 -80
- package/docs/migration-from-oas.md +80 -116
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.1.md +106 -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 +626 -415
- 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/README.md +76 -16
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/docs/turn-record-sot.md +1 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/packages/record/lib/store.mjs +207 -43
- package/skills/oats/SKILL.md +6 -2
- package/capabilities/oats-aweb/package.json +0 -20
- package/capabilities/oats-jira/package.json +0 -25
- package/capabilities/oats-linear/README.md +0 -234
- package/capabilities/oats-linear/package.json +0 -29
- package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
- package/capabilities/oats-okf/package.json +0 -22
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 };
|
|
@@ -4395,7 +4401,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4395
4401
|
// OATS_INSTANCE_HOME is the runtime-neutral contract name for the
|
|
4396
4402
|
// instance home (absolute). OATS_HOME predates it and stays as a
|
|
4397
4403
|
// compatibility alias: shipped capability hooks read it
|
|
4398
|
-
// (
|
|
4404
|
+
// (the official oats.aweb and oats.okf packages) and are versioned
|
|
4399
4405
|
// independently of this kernel. Neither is OATS_HOME_DIR, which is
|
|
4400
4406
|
// the package STORE root — do not conflate them.
|
|
4401
4407
|
OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
|
|
@@ -4665,7 +4671,7 @@ export function appendLogEntry(file, entry, title = "Log") {
|
|
|
4665
4671
|
writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
|
|
4666
4672
|
}
|
|
4667
4673
|
|
|
4668
|
-
// (soul knowledge scaffolding belongs to
|
|
4674
|
+
// (soul knowledge scaffolding belongs to the oats.okf package — soul-scaffold hook)
|
|
4669
4675
|
|
|
4670
4676
|
// ---------- soul scaffolding ----------
|
|
4671
4677
|
function fileSnapshot(dir) {
|
|
@@ -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
|
|
@@ -5333,6 +5364,14 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5333
5364
|
// reconciliation, so this spawn-time check is the authoritative one.
|
|
5334
5365
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
|
|
5335
5366
|
|
|
5367
|
+
// Prerequisites must fail before creating a home, worktree, or identity.
|
|
5368
|
+
const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
|
|
5369
|
+
const bin = which(runtime === "claude" ? claudeBin : runtime);
|
|
5370
|
+
if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
|
|
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;
|
|
5373
|
+
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5374
|
+
|
|
5336
5375
|
mkdirSync(home, { recursive: true });
|
|
5337
5376
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
5338
5377
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -5508,41 +5547,71 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5508
5547
|
// Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
|
|
5509
5548
|
// memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
|
|
5510
5549
|
// messaging integration mints the comms identity. Kernel stays memory-agnostic.
|
|
5511
|
-
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5512
5550
|
const hookRes = runLifecycleHooks("spawn", {
|
|
5513
5551
|
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
5514
5552
|
workspaceDir: workspaceOf(root), resolved: resolvedCfg,
|
|
5515
5553
|
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
|
|
5516
5554
|
});
|
|
5517
5555
|
warnings.push(...hookRes.warnings);
|
|
5518
|
-
// A REQUIRED spawn hook that failed means an active capability is not actually
|
|
5519
|
-
// configured — aweb without a minted identity is an agent that believes it can
|
|
5520
|
-
// be woken by mail and cannot. Fail the spawn and roll back, rather than hand
|
|
5521
|
-
// over a half-configured instance. Nothing is launched yet, so compensation is
|
|
5522
|
-
// retire hooks + worktree/branch + home; the same three-state verification as
|
|
5523
|
-
// the anchor-write path, because a cleanup we cannot confirm must never be
|
|
5524
|
-
// reported as done.
|
|
5525
5556
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
5526
|
-
|
|
5527
|
-
|
|
5557
|
+
let windowMayExist = false;
|
|
5558
|
+
let spawnTmux;
|
|
5559
|
+
let spawnHerdr;
|
|
5560
|
+
const ancillaryCleanup = [];
|
|
5561
|
+
// One compensation owner, from the first hook result through launch and
|
|
5562
|
+
// the final lineage write. Preserve the original failure and retain any
|
|
5563
|
+
// credentials/metadata whose cleanup could not be confirmed.
|
|
5564
|
+
const compensateSpawn = () => {
|
|
5565
|
+
const failed = requiredFailures.length
|
|
5566
|
+
? requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) }))
|
|
5567
|
+
: [{ capability: "oats.kernel", event: "spawn" }];
|
|
5568
|
+
const incomplete = [...ancillaryCleanup];
|
|
5528
5569
|
const probe = (argv) => {
|
|
5529
5570
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
5530
|
-
//
|
|
5531
|
-
|
|
5532
|
-
// every clean probe look like a failed one. `git rev-parse --verify
|
|
5533
|
-
// --quiet` on an absent ref is exactly that case, so a successful branch
|
|
5534
|
-
// deletion could never be confirmed and rollback always reported
|
|
5535
|
-
// INCOMPLETE. Distinguish "no output" from "no stderr captured".
|
|
5536
|
-
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5571
|
+
// An absent ref exits 1 with empty stderr; preserve that distinction.
|
|
5572
|
+
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5537
5573
|
};
|
|
5538
|
-
//
|
|
5539
|
-
// the prose in `incomplete` tells a human what happened, but a retry needs
|
|
5540
|
-
// something it can check. Without this, a retry that resolves no capabilities
|
|
5541
|
-
// at all (a descriptor naming none, or config drift since the spawn) runs zero
|
|
5542
|
-
// hooks, finds zero failures, and clears the quarantine having done nothing
|
|
5543
|
-
// (reviewer-dd03a98).
|
|
5574
|
+
// Retain cleanup owners so a retry must actually discharge their debt.
|
|
5544
5575
|
const outstandingHooks = new Set();
|
|
5545
5576
|
const outstandingGit = new Set();
|
|
5577
|
+
// A failed new-window command may still have created its window. Verify
|
|
5578
|
+
// quiescence before removing credentials or work that runtime may be using.
|
|
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") {
|
|
5596
|
+
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
5597
|
+
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
5598
|
+
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
5599
|
+
if (unresolved) {
|
|
5600
|
+
incomplete.push(!winProbe.ok
|
|
5601
|
+
? `tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`
|
|
5602
|
+
: `tmux window ${session}:${instance} still running`);
|
|
5603
|
+
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5604
|
+
if (work === "worktree") {
|
|
5605
|
+
outstandingGit.add("worktree");
|
|
5606
|
+
if (branch) outstandingGit.add("branch");
|
|
5607
|
+
}
|
|
5608
|
+
return quarantineInstanceHome({
|
|
5609
|
+
home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
5610
|
+
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
5611
|
+
launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
|
|
5612
|
+
});
|
|
5613
|
+
}
|
|
5614
|
+
}
|
|
5546
5615
|
let compensationMeta = {};
|
|
5547
5616
|
try {
|
|
5548
5617
|
const comp = runLifecycleHooks("retire", {
|
|
@@ -5597,25 +5666,13 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5597
5666
|
incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
|
|
5598
5667
|
outstandingHooks.add(cap.id);
|
|
5599
5668
|
}
|
|
5600
|
-
const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
|
|
5601
5669
|
let note;
|
|
5602
|
-
if (
|
|
5603
|
-
//
|
|
5604
|
-
//
|
|
5605
|
-
// <instance-home>/.aw is the only signing key that can self-delete the
|
|
5606
|
-
// remote identity. Removing it converts a transient cleanup failure into
|
|
5607
|
-
// permanent remote residue (aggregate review at 798b156). The worktree and
|
|
5608
|
-
// branch are already gone where that was independently safe; nothing is
|
|
5609
|
-
// launched.
|
|
5610
|
-
// A quarantine cannot promise to prove zero work. If a diagnostic exists
|
|
5611
|
-
// but neither category identified its owner, conservatively require every
|
|
5612
|
-
// retire hook to succeed on retry.
|
|
5613
|
-
if (!outstandingHooks.size && !outstandingGit.size) {
|
|
5614
|
-
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5615
|
-
}
|
|
5670
|
+
if (outstandingHooks.size || outstandingGit.size) {
|
|
5671
|
+
// Preserve credentials and the original hook receipt until cleanup
|
|
5672
|
+
// succeeds. The runtime is stopped; Git cleanup was independently safe.
|
|
5616
5673
|
note = quarantineInstanceHome({
|
|
5617
5674
|
home, instance, agent, incomplete,
|
|
5618
|
-
failed
|
|
5675
|
+
failed,
|
|
5619
5676
|
outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
|
|
5620
5677
|
hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
|
|
5621
5678
|
recordRetirementBaseline: true,
|
|
@@ -5625,342 +5682,258 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5625
5682
|
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
5626
5683
|
note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
|
|
5627
5684
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5685
|
+
return note;
|
|
5686
|
+
};
|
|
5687
|
+
|
|
5688
|
+
try {
|
|
5689
|
+
if (requiredFailures.length) {
|
|
5690
|
+
const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
|
|
5691
|
+
const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
|
|
5692
|
+
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`);
|
|
5693
|
+
}
|
|
5694
|
+
const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
|
|
5695
|
+
const workDesc = work === "worktree"
|
|
5696
|
+
? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
|
|
5697
|
+
: work === "attached"
|
|
5698
|
+
? `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`
|
|
5699
|
+
: work === "workspace"
|
|
5700
|
+
? `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)`
|
|
5701
|
+
: `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`;
|
|
5702
|
+
writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
|
|
5640
5703
|
|
|
5641
5704
|
You are instance "${instance}" of agent "${agent.name}".
|
|
5642
5705
|
- Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
|
|
5643
5706
|
- Work tree: ./work — ${workDesc}
|
|
5644
|
-
- 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" : ""}
|
|
5645
5708
|
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
|
|
5646
5709
|
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
}
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5710
|
+
// Launch command. Spawn IS session start: this command is persisted in
|
|
5711
|
+
// instance.json and executed in the instance's tmux window. Capabilities may
|
|
5712
|
+
// contribute runtime-specific arguments via their spawn hook's `launch` map
|
|
5713
|
+
// (e.g. aweb's Claude Code channel plugin flags).
|
|
5714
|
+
const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
|
|
5715
|
+
let cmdline;
|
|
5716
|
+
if (runtime === "claude") {
|
|
5717
|
+
// .claude/skills already links the OATS-composed instance skill set.
|
|
5718
|
+
// "--" terminates option parsing BEFORE the prompt: capability launch
|
|
5719
|
+
// hooks can contribute greedy/variadic flags (e.g. aweb's
|
|
5720
|
+
// --dangerously-load-development-channels), and without the separator
|
|
5721
|
+
// the TASK.md text is swallowed as that flag's next value — claude
|
|
5722
|
+
// errors out ("entries must be tagged: <task text>") and the window
|
|
5723
|
+
// drops to the fallback shell, which reads as a silently stuck spawn.
|
|
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)"`;
|
|
5736
|
+
} else {
|
|
5737
|
+
// STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
|
|
5738
|
+
// or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
|
|
5739
|
+
// NOT "nothing else can contribute": extensions stay ambient by founder
|
|
5740
|
+
// ruling (see below), and an extension's resources_discover hook can add
|
|
5741
|
+
// skill paths that survive --no-skills. The OATS-managed root is exact; the
|
|
5742
|
+
// extension surface is the operator's, and stating otherwise here would
|
|
5743
|
+
// contradict the paragraph twelve lines down (reviewer-aggregate2).
|
|
5744
|
+
//
|
|
5745
|
+
// --no-skills ends discovery; --skill stays additive.
|
|
5746
|
+
// --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
|
|
5747
|
+
// It also stops the instance's OWN composed AGENTS.md
|
|
5748
|
+
// loading, so that is delivered explicitly via
|
|
5749
|
+
// --append-system-prompt. The work tree's AGENTS.md
|
|
5750
|
+
// stays READABLE by the read tool: readable, not
|
|
5751
|
+
// auto-injected, is the contract.
|
|
5752
|
+
// --no-prompt-templates same posture for ambient prompt templates.
|
|
5753
|
+
//
|
|
5754
|
+
// Built-in tools and pi's native interaction model are untouched — OATS
|
|
5755
|
+
// curates the curriculum, it does not cripple the runtime.
|
|
5756
|
+
//
|
|
5757
|
+
// EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
|
|
5758
|
+
// extensions (web search, output formatting) that every instance should
|
|
5759
|
+
// keep. So no --no-extensions, and no -e flags either — pi discovers the
|
|
5760
|
+
// installed extensions itself, and passing them explicitly as well would
|
|
5761
|
+
// load the same extension twice.
|
|
5762
|
+
//
|
|
5763
|
+
// The trade-off is deliberate and narrow: an extension's
|
|
5764
|
+
// `resources_discover` hook can contribute skill paths that survive
|
|
5765
|
+
// --no-skills. Today only the OATS bridge does that, and inside an instance
|
|
5766
|
+
// it contributes that instance's OWN .agents/skills, so the composed set is
|
|
5767
|
+
// unchanged. A third-party extension that contributes skills WOULD add them,
|
|
5768
|
+
// which is the accepted residue of keeping shared extensions working.
|
|
5769
|
+
// Capability-required runtime packages are still verified and recorded
|
|
5770
|
+
// (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
|
|
5771
|
+
// still holds — it is loaded by pi's own discovery rather than by flag.
|
|
5772
|
+
// pi has no `--` end-of-options marker (it rejects `--` in every position),
|
|
5773
|
+
// so the task positional goes AHEAD of capability-contributed options:
|
|
5774
|
+
// nothing preceding it is waiting for a value, so a trailing variadic
|
|
5775
|
+
// contributed flag cannot consume the task.
|
|
5776
|
+
cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
|
|
5777
|
+
+ ` --no-context-files --no-prompt-templates`
|
|
5778
|
+
+ ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
|
|
5779
|
+
+ ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
|
|
5780
|
+
}
|
|
5781
|
+
// OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
|
|
5782
|
+
// the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
|
|
5783
|
+
// are pi-branded predecessors kept as compatibility aliases: the separately
|
|
5784
|
+
// published @awebai/oats-pi extension and bin/oats.mjs still read them, and
|
|
5785
|
+
// an older installed extension must keep working against a newer kernel.
|
|
5786
|
+
const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
|
|
5787
|
+
cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
|
|
5788
|
+
|
|
5789
|
+
const meta = {
|
|
5790
|
+
agent: agent.name, kind: agent.kind || "persistent", instance, home,
|
|
5791
|
+
repo: repoAbs, work, branch, runtime, model: model || undefined,
|
|
5792
|
+
...(yolo !== undefined ? { yolo } : {}),
|
|
5793
|
+
team: resolvedCfg.team || undefined,
|
|
5794
|
+
parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
|
|
5795
|
+
siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
|
|
5796
|
+
relation: relation || undefined,
|
|
5797
|
+
relativeTo: relation ? relativeTo : undefined,
|
|
5798
|
+
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
5799
|
+
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
5800
|
+
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
5801
|
+
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
5802
|
+
id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
|
|
5803
|
+
settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
|
|
5804
|
+
hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
|
|
5805
|
+
...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
|
|
5806
|
+
})),
|
|
5807
|
+
skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
|
|
5808
|
+
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
5809
|
+
// The auditable record of the curriculum: what the resolved composition
|
|
5810
|
+
// PROMISED, and what actually landed. Both are asserted equal before launch;
|
|
5811
|
+
// keeping both makes an instance's surface reviewable after the fact without
|
|
5812
|
+
// re-resolving config that may since have changed.
|
|
5813
|
+
composition: {
|
|
5814
|
+
expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
|
|
5815
|
+
materialized: {
|
|
5816
|
+
skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
|
|
5817
|
+
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
5818
|
+
// `filtered` records that the operator's settings entry narrows this
|
|
5819
|
+
// package's resources. A non-empty filter is a deliberate choice whose
|
|
5820
|
+
// glob semantics belong to the runtime, so it is auditable here rather
|
|
5821
|
+
// than second-guessed at spawn.
|
|
5822
|
+
runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
|
|
5823
|
+
// What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
|
|
5824
|
+
// so the deviation from strict composition is auditable instead of
|
|
5825
|
+
// implied — the honest contract, not an aspiration.
|
|
5826
|
+
runtimePosture: runtime === "claude"
|
|
5827
|
+
? {
|
|
5828
|
+
oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
|
|
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"],
|
|
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.",
|
|
5831
|
+
}
|
|
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
|
+
} : {
|
|
5837
|
+
oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
|
|
5838
|
+
curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
|
|
5839
|
+
ambient: ["globally configured pi extensions, and any resources they contribute"],
|
|
5840
|
+
why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
|
|
5841
|
+
},
|
|
5842
|
+
canonicalSkillTree: join(home, ".agents", "skills"),
|
|
5843
|
+
skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
|
|
5844
|
+
},
|
|
5768
5845
|
},
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
}
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5796
|
-
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
5797
|
-
// Wrap the command so the window drops into an interactive shell when the
|
|
5798
|
-
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
5799
|
-
const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
|
|
5800
|
-
sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
|
|
5801
|
-
launched = true;
|
|
5802
|
-
} else {
|
|
5803
|
-
meta.launched = false;
|
|
5804
|
-
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5805
|
-
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
|
|
5806
|
-
}
|
|
5807
|
-
|
|
5808
|
-
// parent relation: re-point the ANCHOR's recorded lineage so its parent is
|
|
5809
|
-
// the new instance (e.g. a maintainer reviewing the spawner sits above it).
|
|
5810
|
-
// Committed LAST — after every other fallible step incl. launch — so a
|
|
5811
|
-
// failed spawn (missing tmux, window collision, new-window error) never
|
|
5812
|
-
// leaves the anchor's graph pointing at a zombie. --no-launch reaches here
|
|
5813
|
-
// too: the scaffold itself succeeded, which is that path's definition of
|
|
5814
|
-
// success. The write ITSELF is fallible (anchor retired concurrently,
|
|
5815
|
-
// unwritable file): on failure the spawn is COMPENSATED — kill the launched
|
|
5816
|
-
// window and remove the scaffold — so the operation stays all-or-nothing:
|
|
5817
|
-
// either agent live + lineage recorded, or neither.
|
|
5818
|
-
if (relation === "parent" && anchorMeta && anchorMetaPath) {
|
|
5819
|
-
// Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
|
|
5820
|
-
// failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
|
|
5821
|
-
// Write a same-directory temp file and rename it over the anchor — rename
|
|
5822
|
-
// is atomic on POSIX, so the anchor is always either old or new, never
|
|
5823
|
-
// truncated.
|
|
5824
|
-
const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
|
|
5825
|
-
try {
|
|
5826
|
-
anchorMeta.parentInstance = instance;
|
|
5827
|
-
delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
|
|
5828
|
-
writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
|
|
5829
|
-
renameSync(tmpPath, anchorMetaPath);
|
|
5830
|
-
} catch (e) {
|
|
5831
|
-
// Compensation steps are each independent and best-effort: no step may
|
|
5832
|
-
// abort the remaining rollback or mask the original anchor-write error
|
|
5833
|
-
// (rmSync force:true only suppresses ENOENT — EPERM/IO still throw).
|
|
5834
|
-
// Failures are COLLECTED and reported: the thrown message must never
|
|
5835
|
-
// claim a cleanup that did not happen.
|
|
5836
|
-
const incomplete = [];
|
|
5837
|
-
// Verification probes are argv-based (no shell interpolation — branch
|
|
5838
|
-
// names may contain valid-but-hostile metacharacters like $(…)) and
|
|
5839
|
-
// THREE-STATE: confirmed-absent | still-present | could-not-verify.
|
|
5840
|
-
// Both of the latter are reported — a failed probe must never pass as
|
|
5841
|
-
// a confirmed cleanup (fail closed).
|
|
5842
|
-
const probe = (argv) => {
|
|
5843
|
-
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
5844
|
-
// With encoding:"utf8" a silent command yields stderr === "" — FALSY — so
|
|
5845
|
-
// `e2.stderr || e2.message` fell through to "Command failed: …" and made
|
|
5846
|
-
// every clean probe look like a failed one. `git rev-parse --verify
|
|
5847
|
-
// --quiet` on an absent ref is exactly that case, so a successful branch
|
|
5848
|
-
// deletion could never be confirmed and rollback always reported
|
|
5849
|
-
// INCOMPLETE. Distinguish "no output" from "no stderr captured".
|
|
5850
|
-
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5851
|
-
};
|
|
5852
|
-
let windowUnresolved = false;
|
|
5853
|
-
try { rmSync(tmpPath, { force: true }); } catch (e2) { incomplete.push(`temp file ${tmpPath}: ${e2.message}`); }
|
|
5854
|
-
if (launched) {
|
|
5855
|
-
// shTry returns "" on success and undefined on failure — neither is a
|
|
5856
|
-
// reliable signal for kill-window, so verify the EFFECT unconditionally.
|
|
5857
|
-
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
5858
|
-
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
5859
|
-
if (!winProbe.ok) { incomplete.push(`tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`); windowUnresolved = true; }
|
|
5860
|
-
else if (winProbe.out.split("\n").includes(instance)) { incomplete.push(`tmux window ${session}:${instance} still running`); windowUnresolved = true; }
|
|
5846
|
+
capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
|
|
5847
|
+
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
5848
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
5849
|
+
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
5850
|
+
executable: cap.executable,
|
|
5851
|
+
})),
|
|
5852
|
+
...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
|
|
5853
|
+
command: cmdline, createdAt: new Date().toISOString(),
|
|
5854
|
+
};
|
|
5855
|
+
const spawnWarnings = warnings;
|
|
5856
|
+
|
|
5857
|
+
spawnTmux = meta.tmux;
|
|
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) {
|
|
5867
|
+
if (!tmuxAlive(session)) {
|
|
5868
|
+
const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
|
|
5869
|
+
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
5870
|
+
shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
|
|
5871
|
+
shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
|
|
5861
5872
|
}
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5873
|
+
if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
|
|
5874
|
+
meta.tmux.socket = tmuxSocket(session);
|
|
5875
|
+
meta.launched = true;
|
|
5876
|
+
// Commit the final child metadata and its independent byte authority before
|
|
5877
|
+
// the managed runtime can write. No child-home transition follows launch.
|
|
5878
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5879
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
5880
|
+
// Wrap the command so the window drops into an interactive shell when the
|
|
5881
|
+
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
5882
|
+
const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
|
|
5883
|
+
windowMayExist = true;
|
|
5884
|
+
sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
|
|
5885
|
+
} else {
|
|
5886
|
+
meta.launched = false;
|
|
5887
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5888
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
|
|
5889
|
+
}
|
|
5890
|
+
|
|
5891
|
+
// parent relation: re-point the ANCHOR's recorded lineage so its parent is
|
|
5892
|
+
// the new instance (e.g. a maintainer reviewing the spawner sits above it).
|
|
5893
|
+
// Committed LAST — after every other fallible step incl. launch — so a
|
|
5894
|
+
// failed spawn (missing tmux, window collision, new-window error) never
|
|
5895
|
+
// leaves the anchor's graph pointing at a zombie. --no-launch reaches here
|
|
5896
|
+
// too: the scaffold itself succeeded, which is that path's definition of
|
|
5897
|
+
// success. The write ITSELF is fallible (anchor retired concurrently,
|
|
5898
|
+
// unwritable file): on failure the spawn is COMPENSATED — kill the launched
|
|
5899
|
+
// window and remove the scaffold — so the operation stays all-or-nothing:
|
|
5900
|
+
// either agent live + lineage recorded, or neither.
|
|
5901
|
+
if (relation === "parent" && anchorMeta && anchorMetaPath) {
|
|
5902
|
+
// Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
|
|
5903
|
+
// failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
|
|
5904
|
+
// Write a same-directory temp file and rename it over the anchor — rename
|
|
5905
|
+
// is atomic on POSIX, so the anchor is always either old or new, never
|
|
5906
|
+
// truncated.
|
|
5907
|
+
const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
|
|
5867
5908
|
try {
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
// Exit 0 while reporting "not retired" is also unfinished cleanup.
|
|
5877
|
-
for (const [capId, m] of Object.entries(comp.meta || {})) {
|
|
5878
|
-
if (m && typeof m === "object" && m.retired === false && m.reason !== "nothing-to-delete") {
|
|
5879
|
-
incomplete.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""} — external state may remain`);
|
|
5880
|
-
outstandingHooks.add(capId);
|
|
5881
|
-
}
|
|
5882
|
-
}
|
|
5883
|
-
compMeta = { ...compMeta, ...(comp.meta || {}) };
|
|
5884
|
-
} catch (e2) {
|
|
5885
|
-
incomplete.push(`retire hooks: ${e2.message}`);
|
|
5886
|
-
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5887
|
-
}
|
|
5888
|
-
if (work === "worktree") {
|
|
5889
|
-
const wt = join(home, "work");
|
|
5890
|
-
// Git canonicalizes symlinked parent components when registering a
|
|
5891
|
-
// worktree. Use the path captured immediately after successful add —
|
|
5892
|
-
// retire-hook compensation may already have removed/inaccessible'd wt.
|
|
5893
|
-
const wtCanonical = worktreeCanonical;
|
|
5894
|
-
if (!wtCanonical) incomplete.push(`git worktree ${wt}: could not verify removal (canonical path unavailable)`);
|
|
5895
|
-
probe(["git", "-C", repoAbs, "worktree", "remove", "--force", wt]);
|
|
5896
|
-
probe(["git", "-C", repoAbs, "worktree", "prune"]);
|
|
5897
|
-
// Verify effects, not exit codes: parse exact NUL-delimited `worktree`
|
|
5898
|
-
// records (never substring-match a lexical path against Git's canonical
|
|
5899
|
-
// registered path). The tree must be deregistered or later rmSync(home)
|
|
5900
|
-
// strands stale Git metadata.
|
|
5901
|
-
const wtProbe = probe(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
|
|
5902
|
-
if (!wtProbe.ok) { incomplete.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`); outstandingGit.add("worktree"); }
|
|
5903
|
-
else {
|
|
5904
|
-
const registered = wtProbe.out.split("\0").filter((field) => field.startsWith("worktree ")).map((field) => field.slice("worktree ".length));
|
|
5905
|
-
if (wtCanonical && registered.includes(wtCanonical)) { incomplete.push(`git worktree ${wtCanonical}: still registered`); outstandingGit.add("worktree"); }
|
|
5906
|
-
}
|
|
5907
|
-
if (branch) {
|
|
5908
|
-
probe(["git", "-C", repoAbs, "branch", "-D", branch]);
|
|
5909
|
-
// rev-parse --verify: exit 0 = ref exists; exit 1 with empty stderr
|
|
5910
|
-
// under --quiet = confirmed absent; anything else = could not verify.
|
|
5911
|
-
const brProbe = probe(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
5912
|
-
if (brProbe.ok) { incomplete.push(`git branch ${branch}: still exists`); outstandingGit.add("branch"); }
|
|
5913
|
-
else if (brProbe.status !== 1 || brProbe.err) { incomplete.push(`git branch ${branch}: could not verify deletion (${brProbe.err || `rev-parse exit ${brProbe.status}`})`); outstandingGit.add("branch"); }
|
|
5914
|
-
}
|
|
5915
|
-
}
|
|
5916
|
-
// QUARANTINE, do not delete. This path deleted the home unconditionally —
|
|
5917
|
-
// including while its own retire hook was reporting failure — which is
|
|
5918
|
-
// exactly the credential destruction the required-hook path was fixed to
|
|
5919
|
-
// avoid (reviewer-terminal54a87fd). A quarantine with nothing outstanding
|
|
5920
|
-
// would be a proof obligation of zero, so the record is filled
|
|
5921
|
-
// conservatively when both categories somehow came up empty.
|
|
5922
|
-
// Quarantine only for state that COMPENSATION owns and did not finish: a
|
|
5923
|
-
// retire hook that failed, Git the rollback could not undo, or a window
|
|
5924
|
-
// still running. Litter beside the anchor (a leftover temp file) is
|
|
5925
|
-
// reported but is not the child's external state, and retaining a home
|
|
5926
|
-
// for it would turn an ordinary failure into a --force cleanup.
|
|
5927
|
-
const unresolved = outstandingHooks.size > 0 || outstandingGit.size > 0 || windowUnresolved;
|
|
5928
|
-
let rollbackNote;
|
|
5929
|
-
if (unresolved) {
|
|
5930
|
-
if (!outstandingHooks.size && !outstandingGit.size) {
|
|
5931
|
-
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5932
|
-
}
|
|
5933
|
-
rollbackNote = quarantineInstanceHome({
|
|
5934
|
-
home, instance, agent, incomplete,
|
|
5935
|
-
failed: [{ capability: "oats.kernel", event: "spawn" }],
|
|
5936
|
-
outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
|
|
5937
|
-
// The SPAWN metadata — the aweb alias a retry needs to delete the
|
|
5938
|
-
// remote identity. Passing the compensation result overwrote it with
|
|
5939
|
-
// `{retired:false}`, discarding the very handle cleanup requires.
|
|
5940
|
-
hookMeta: hookRes.meta || {}, compensationMeta: compMeta,
|
|
5941
|
-
launched: windowUnresolved, tmux: meta.tmux,
|
|
5942
|
-
recordRetirementBaseline: true,
|
|
5943
|
-
}).replace(/^ — /, "");
|
|
5944
|
-
} else {
|
|
5945
|
-
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
5946
|
-
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
5947
|
-
rollbackNote = incomplete.length
|
|
5948
|
-
? `rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}`
|
|
5949
|
-
: "spawn rolled back (window killed, hooks compensated, scaffold removed)";
|
|
5909
|
+
anchorMeta.parentInstance = instance;
|
|
5910
|
+
delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
|
|
5911
|
+
writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
|
|
5912
|
+
renameSync(tmpPath, anchorMetaPath);
|
|
5913
|
+
} catch (e) {
|
|
5914
|
+
try { rmSync(tmpPath, { force: true }); }
|
|
5915
|
+
catch (cleanupError) { ancillaryCleanup.push(`temp file ${tmpPath}: ${cleanupError.message}`); }
|
|
5916
|
+
throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message})`);
|
|
5950
5917
|
}
|
|
5951
|
-
throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message}) — ${rollbackNote}`);
|
|
5952
5918
|
}
|
|
5953
|
-
}
|
|
5954
5919
|
|
|
5955
|
-
|
|
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 };
|
|
5921
|
+
} catch (error) {
|
|
5922
|
+
const note = compensateSpawn();
|
|
5923
|
+
error.message += note;
|
|
5924
|
+
throw error;
|
|
5925
|
+
}
|
|
5956
5926
|
}
|
|
5957
5927
|
|
|
5958
5928
|
export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
5959
5929
|
const windows = tmuxWindows(tmuxSession);
|
|
5960
5930
|
const readInstancesOf = (agentDir) => {
|
|
5961
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).
|
|
5962
5935
|
return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
|
|
5963
|
-
.filter((e) => e.isDirectory())
|
|
5936
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
5964
5937
|
.map((e) => {
|
|
5965
5938
|
const metaPath = join(instancesDir, e.name, "instance.json");
|
|
5966
5939
|
const home = join(instancesDir, e.name);
|
|
@@ -5975,12 +5948,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5975
5948
|
try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
|
|
5976
5949
|
catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
|
|
5977
5950
|
}
|
|
5978
|
-
|
|
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
|
+
|
|
5979
5967
|
});
|
|
5980
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
|
+
};
|
|
5981
5989
|
const out = listAgents(root).map((a) => {
|
|
5982
5990
|
const { _dir, ...soul } = a;
|
|
5983
|
-
return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
|
|
5991
|
+
return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
|
|
5984
5992
|
});
|
|
5985
5993
|
// Capability-defined agents home under local-agents/<name>/ WITHOUT a local
|
|
5986
5994
|
// soul (it lives read-only in the package) — surface their instances too.
|
|
@@ -5990,9 +5998,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5990
5998
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
5991
5999
|
if (!e.isDirectory() || seen.has(e.name)) continue;
|
|
5992
6000
|
const instances = readInstancesOf(join(dir, e.name));
|
|
5993
|
-
|
|
6001
|
+
const retireFailures = readRetireFailuresOf(join(dir, e.name));
|
|
6002
|
+
if (!instances.length && !retireFailures.length) continue;
|
|
5994
6003
|
const cap = instances.find((i) => i.capability)?.capability;
|
|
5995
|
-
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 } : {}) });
|
|
5996
6005
|
seen.add(e.name);
|
|
5997
6006
|
}
|
|
5998
6007
|
}
|
|
@@ -6068,14 +6077,9 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
|
|
|
6068
6077
|
/** The rollback-owned Git steps a quarantine can still owe. */
|
|
6069
6078
|
export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
|
|
6070
6079
|
|
|
6071
|
-
/** Retain
|
|
6072
|
-
* finish the
|
|
6073
|
-
|
|
6074
|
-
* already launched (re-pointing a parent anchor) — and the second one deleted the
|
|
6075
|
-
* home while its own retire hook was reporting failure, destroying the credential
|
|
6076
|
-
* needed to undo the external state that survived (reviewer-terminal54a87fd).
|
|
6077
|
-
* Two copies of this logic is how that divergence happened; there is now one. */
|
|
6078
|
-
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
|
|
6080
|
+
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
6081
|
+
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
6082
|
+
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
|
|
6079
6083
|
try {
|
|
6080
6084
|
writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
|
|
6081
6085
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
@@ -6091,6 +6095,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
6091
6095
|
cleanup: {
|
|
6092
6096
|
version: QUARANTINE_CLEANUP_VERSION,
|
|
6093
6097
|
repo: repoAbs, work, branch, launched, tmux,
|
|
6098
|
+
...(sessionTarget ? { sessionTarget } : {}),
|
|
6094
6099
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
|
|
6095
6100
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
6096
6101
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
@@ -6105,7 +6110,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
6105
6110
|
} catch { /* the quarantine still stands without its marker */ }
|
|
6106
6111
|
if (recordRetirementBaseline) {
|
|
6107
6112
|
try {
|
|
6108
|
-
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 });
|
|
6109
6114
|
} catch (e) {
|
|
6110
6115
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
6111
6116
|
}
|
|
@@ -6179,6 +6184,15 @@ function retirementBaselinePath(home) {
|
|
|
6179
6184
|
return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
|
|
6180
6185
|
}
|
|
6181
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
|
+
|
|
6182
6196
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
6183
6197
|
const hash = createHash("sha256");
|
|
6184
6198
|
const rootStat = lstatSync(root);
|
|
@@ -6208,7 +6222,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
6208
6222
|
|
|
6209
6223
|
function worktreeStatus(repo) {
|
|
6210
6224
|
try {
|
|
6211
|
-
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 });
|
|
6212
6226
|
} catch (e) {
|
|
6213
6227
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
|
|
6214
6228
|
}
|
|
@@ -6259,6 +6273,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
|
|
|
6259
6273
|
runtime: {
|
|
6260
6274
|
launched: runtime?.launched === true,
|
|
6261
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 } : {}),
|
|
6262
6277
|
},
|
|
6263
6278
|
};
|
|
6264
6279
|
const path = retirementBaselinePath(home);
|
|
@@ -6284,7 +6299,7 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6284
6299
|
if (!repo || !branch) return [];
|
|
6285
6300
|
const target = `refs/heads/${branch}`;
|
|
6286
6301
|
try {
|
|
6287
|
-
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 });
|
|
6288
6303
|
} catch (e) {
|
|
6289
6304
|
const detail = String(e.stderr ?? "").trim();
|
|
6290
6305
|
// A quarantine retry may follow a successful rollback-owned branch removal.
|
|
@@ -6293,9 +6308,9 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6293
6308
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6294
6309
|
}
|
|
6295
6310
|
try {
|
|
6296
|
-
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 })
|
|
6297
6312
|
.split("\n").filter((ref) => ref && ref !== target);
|
|
6298
|
-
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);
|
|
6299
6314
|
} catch (e) {
|
|
6300
6315
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6301
6316
|
}
|
|
@@ -6305,11 +6320,55 @@ function runtimeAuthorityOf(baseline) {
|
|
|
6305
6320
|
const runtime = baseline?.runtime;
|
|
6306
6321
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
6307
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
|
+
}
|
|
6308
6327
|
const tmux = runtime.tmux;
|
|
6309
6328
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
6310
6329
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
6311
6330
|
}
|
|
6312
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
|
+
|
|
6313
6372
|
function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
|
|
6314
6373
|
const classes = [];
|
|
6315
6374
|
let baseline;
|
|
@@ -6356,14 +6415,14 @@ const RECOVERABLE_GIT_ADMIN = [
|
|
|
6356
6415
|
];
|
|
6357
6416
|
|
|
6358
6417
|
function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
6359
|
-
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();
|
|
6360
6419
|
const recoveredGit = join(recoveredRepo, ".git");
|
|
6361
|
-
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 });
|
|
6362
6421
|
for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
|
|
6363
6422
|
const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
|
|
6364
6423
|
if (!match) continue;
|
|
6365
|
-
const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]]);
|
|
6366
|
-
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();
|
|
6367
6426
|
if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
|
|
6368
6427
|
}
|
|
6369
6428
|
copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
|
|
@@ -6378,11 +6437,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
|
6378
6437
|
|
|
6379
6438
|
function detachRecoveryClone(source, recovered) {
|
|
6380
6439
|
let stash;
|
|
6381
|
-
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(); }
|
|
6382
6441
|
catch { stash = undefined; }
|
|
6383
6442
|
if (stash) {
|
|
6384
|
-
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"]);
|
|
6385
|
-
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();
|
|
6386
6445
|
const stashLog = join(sourceCommon, "logs", "refs", "stash");
|
|
6387
6446
|
if (existsSync(stashLog)) {
|
|
6388
6447
|
const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
|
|
@@ -6390,17 +6449,17 @@ function detachRecoveryClone(source, recovered) {
|
|
|
6390
6449
|
copyFileSync(stashLog, recoveredLog);
|
|
6391
6450
|
}
|
|
6392
6451
|
}
|
|
6393
|
-
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 */ }
|
|
6394
6453
|
}
|
|
6395
6454
|
|
|
6396
6455
|
function materializeNestedRepositories(sourceWork, recoveredRepo) {
|
|
6397
6456
|
for (const source of nestedGitRoots(sourceWork)) {
|
|
6398
6457
|
const rel = relative(sourceWork, source);
|
|
6399
6458
|
const dest = join(recoveredRepo, rel);
|
|
6400
|
-
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();
|
|
6401
6460
|
rmSync(dest, { recursive: true, force: true });
|
|
6402
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6403
|
-
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 });
|
|
6404
6463
|
detachRecoveryClone(source, dest);
|
|
6405
6464
|
restoreStandaloneGitState(source, dest);
|
|
6406
6465
|
for (const e of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -6427,7 +6486,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6427
6486
|
}
|
|
6428
6487
|
if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6429
6488
|
const recoveredRepo = join(staging, "repo");
|
|
6430
|
-
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 });
|
|
6431
6490
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
6432
6491
|
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
6433
6492
|
if (existsSync(observation.work)) {
|
|
@@ -6445,8 +6504,8 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6445
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");
|
|
6446
6505
|
if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
|
|
6447
6506
|
}
|
|
6448
|
-
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
6449
|
-
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();
|
|
6450
6509
|
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
6451
6510
|
}
|
|
6452
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 });
|
|
@@ -6459,9 +6518,143 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6459
6518
|
}
|
|
6460
6519
|
}
|
|
6461
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
|
+
|
|
6462
6652
|
export function retireInstance(root, name, o = {}) {
|
|
6463
6653
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
6464
|
-
|
|
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;
|
|
6465
6658
|
const found = findInstanceHome(root, name);
|
|
6466
6659
|
if (!found) throw new Error(`no instance named "${name}"`);
|
|
6467
6660
|
const metaPath = join(found.home, "instance.json");
|
|
@@ -6498,6 +6691,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6498
6691
|
if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
|
|
6499
6692
|
meta.launched = quarantine.cleanup.launched;
|
|
6500
6693
|
meta.tmux = quarantine.cleanup.tmux;
|
|
6694
|
+
meta.sessionTarget = quarantine.cleanup.sessionTarget;
|
|
6501
6695
|
}
|
|
6502
6696
|
// A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
|
|
6503
6697
|
// retired safely: hooks would be skipped and the directory removed, which is
|
|
@@ -6517,8 +6711,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6517
6711
|
const workPath = join(found.home, "work");
|
|
6518
6712
|
const isWorktree = meta.work === "worktree" ||
|
|
6519
6713
|
(existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
|
|
6520
|
-
|
|
6521
|
-
|
|
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);
|
|
6522
6721
|
}
|
|
6523
6722
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
6524
6723
|
// the managed runtime; recovery copying never races a live managed Pi.
|
|
@@ -6533,11 +6732,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6533
6732
|
if (!runtimeAuthority) {
|
|
6534
6733
|
throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
|
|
6535
6734
|
}
|
|
6536
|
-
const
|
|
6537
|
-
meta.tmux?.
|
|
6538
|
-
meta.tmux?.
|
|
6539
|
-
|
|
6540
|
-
|
|
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);
|
|
6541
6741
|
if (!metaAgrees) {
|
|
6542
6742
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
6543
6743
|
}
|
|
@@ -6545,7 +6745,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6545
6745
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
6546
6746
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
6547
6747
|
// window absence established before recovery copying begins.
|
|
6548
|
-
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) {
|
|
6549
6753
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
6550
6754
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
6551
6755
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -6588,13 +6792,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6588
6792
|
// describes. A hook that reports nothing, or reports retired:true, is
|
|
6589
6793
|
// unaffected; only an explicit "I did not finish" changes the outcome.
|
|
6590
6794
|
let ordinaryIncomplete = [];
|
|
6591
|
-
//
|
|
6592
|
-
//
|
|
6593
|
-
//
|
|
6594
|
-
//
|
|
6595
|
-
//
|
|
6596
|
-
//
|
|
6597
|
-
// 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.
|
|
6598
6801
|
if (!quarantine && !self) {
|
|
6599
6802
|
for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
|
|
6600
6803
|
// A hook may exit 0 and still report it did not finish. Only an explicit
|
|
@@ -6777,7 +6980,15 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6777
6980
|
// unreachable remote) would be unremovable through OATS forever — the same
|
|
6778
6981
|
// dead end the unusable-marker fixes closed, just reached from a valid one.
|
|
6779
6982
|
const forced = !!(stillIncomplete && o.force);
|
|
6780
|
-
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
|
+
}
|
|
6781
6992
|
|
|
6782
6993
|
|
|
6783
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 };
|