@awebai/oats 0.22.1 → 0.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/oats.mjs +302 -19
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +211 -6
- package/capabilities/oats-aweb/injects/aweb.md +10 -3
- package/capabilities/oats-aweb/oats.json +40 -7
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +3 -1
- package/capabilities/oats-okf/bin/oats-okf.mjs +125 -9
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +2 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/docs/capability-manifest.schema.json +41 -0
- package/docs/execution-targets.md +210 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +36 -0
- package/docs/migration-from-oas.md +1 -1
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +269 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/release-notes/v0.22.3.md +80 -0
- package/docs/servers.md +145 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +528 -78
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +623 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/skills/oats/SKILL.md +6 -2
package/lib/core.mjs
CHANGED
|
@@ -23,17 +23,20 @@
|
|
|
23
23
|
* soul.yaml (flat key: value):
|
|
24
24
|
* name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
|
|
25
25
|
* repo (path rel. to workspace or absolute),
|
|
26
|
-
* work (worktree|checkout|attached), runtime (pi|claude), model (pi model pattern, optional)
|
|
26
|
+
* work (worktree|checkout|attached), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
27
27
|
* (attached as soul default is for service agents — spawn must supply workDir)
|
|
28
28
|
*/
|
|
29
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
29
|
+
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
30
30
|
import {
|
|
31
|
-
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
31
|
+
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
33
33
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
34
34
|
import { homedir, tmpdir } from "node:os";
|
|
35
35
|
import { createHash } from "node:crypto";
|
|
36
36
|
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
38
|
+
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
39
|
+
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget } from "./herdr.mjs";
|
|
37
40
|
|
|
38
41
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
39
42
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
@@ -426,7 +429,7 @@ export const RETIRED_CAPABILITIES = {
|
|
|
426
429
|
export function retiredCapabilityReason(id) {
|
|
427
430
|
return Object.hasOwn(RETIRED_CAPABILITIES, id) ? RETIRED_CAPABILITIES[id] : undefined;
|
|
428
431
|
}
|
|
429
|
-
const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates"]);
|
|
432
|
+
const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo"]);
|
|
430
433
|
/** Renamed-key tables are read with OWN-property semantics only: a config key
|
|
431
434
|
* spelled `constructor`/`toString` inherits a value from `Object.prototype`,
|
|
432
435
|
* and the plain `TABLE[key]` lookup then reported it as the migration hint —
|
|
@@ -467,6 +470,7 @@ function loadLevelConfig(dir) {
|
|
|
467
470
|
* Shared by the level loader and package profile validation (a profile is
|
|
468
471
|
* config source material and must pass the same shape checks). */
|
|
469
472
|
export function validateConfigShape(cfg, file) {
|
|
473
|
+
if (cfg.yolo !== undefined && typeof cfg.yolo !== "boolean") throw new Error(`yolo in ${file} must be true or false`);
|
|
470
474
|
for (const key of Object.keys(cfg)) {
|
|
471
475
|
if (Object.hasOwn(RENAMED_CONFIG_KEYS, key)) throw new Error(`unsupported oats-config key "${key}" in ${file} — ${RENAMED_CONFIG_KEYS[key]}`);
|
|
472
476
|
if (!CONFIG_KEYS.has(key)) throw new Error(`unsupported oats-config key in ${file}: ${key}`);
|
|
@@ -667,7 +671,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
667
671
|
}
|
|
668
672
|
}
|
|
669
673
|
const ranked = [...list].sort((a, b) => a.specificity - b.specificity || b.scope - a.scope || a.target.localeCompare(b.target));
|
|
670
|
-
const settings =
|
|
674
|
+
const settings = Object.create(null); // manifest-declared names such as "constructor" must read as unset
|
|
671
675
|
// Setting names come from parsed config, so the RANK table — a key→info
|
|
672
676
|
// lookup, unlike `settings` itself, which is data handed to consumers — is
|
|
673
677
|
// null-prototype: an inherited name must not read as an already-taken rank.
|
|
@@ -681,12 +685,28 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
681
685
|
settings[key] = value; settingRank[key] = rank;
|
|
682
686
|
}
|
|
683
687
|
}
|
|
688
|
+
// Declared defaults (manifest `settings: { <name>: { default } }`) are part
|
|
689
|
+
// of the effective settings: a requirement conditional on a setting must
|
|
690
|
+
// see the default for a deployment that never set it, or the default path
|
|
691
|
+
// silently loses its requirements (aweb review of oats.aweb 1.10.0).
|
|
692
|
+
const declaredSettings = manifest.settings && typeof manifest.settings === "object" && !Array.isArray(manifest.settings) ? manifest.settings : {};
|
|
693
|
+
for (const [key, decl] of Object.entries(declaredSettings)) {
|
|
694
|
+
if (!decl || typeof decl !== "object") continue;
|
|
695
|
+
if (settings[key] === undefined && decl.default !== undefined) settings[key] = decl.default;
|
|
696
|
+
}
|
|
684
697
|
const strongest = [...list].sort((a, b) => b.specificity - a.specificity || a.scope - b.scope || a.target.localeCompare(b.target));
|
|
685
698
|
const top = strongest[0];
|
|
686
699
|
const tied = strongest.filter((c) => c.specificity === top.specificity && c.scope === top.scope);
|
|
687
700
|
const enabledValues = new Set(tied.map((c) => c.binding.enabled === undefined ? true : !!c.binding.enabled));
|
|
688
701
|
if (enabledValues.size > 1) throw new Error(`ambiguous enabled/excluded bindings for ${id} at equal specificity (${tied.map((c) => c.target).join(", ")})`);
|
|
689
702
|
if (![...enabledValues][0]) continue;
|
|
703
|
+
// A declared value set is enforced for an ACTIVE capability: a misspelling
|
|
704
|
+
// must not switch a conditional requirement off by matching nothing.
|
|
705
|
+
for (const [key, decl] of Object.entries(declaredSettings)) {
|
|
706
|
+
if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
|
|
707
|
+
throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
690
710
|
const compatibility = capabilityCompatibility(manifest);
|
|
691
711
|
if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
|
|
692
712
|
const trust = capabilityTrust(manifest, contextDir);
|
|
@@ -699,7 +719,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
699
719
|
active.push({
|
|
700
720
|
id, capability: id, manifest, layer: manifest.layer, command: manifest.command,
|
|
701
721
|
level: top.level, origin: manifest._origin, provenance: list.map((c) => `${c.target} @ ${c.level}`),
|
|
702
|
-
settings, skills: capabilitySkillDirs(id, contextDir), inject,
|
|
722
|
+
settings: { ...settings }, skills: capabilitySkillDirs(id, contextDir), inject,
|
|
703
723
|
// What the manifest PROMISES, so preflight can tell "declared nothing"
|
|
704
724
|
// from "declared and missing" — the two are indistinguishable in the
|
|
705
725
|
// resolved lists above.
|
|
@@ -713,6 +733,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
713
733
|
// what required:true claims to prevent (aggregate review at 798b156).
|
|
714
734
|
requiredHooks: manifestRequiredHooks(manifest),
|
|
715
735
|
environment: trust.trusted ? [...(manifest.environment || [])] : [],
|
|
736
|
+
environmentNamespaces: trust.trusted ? [...(manifest.environmentNamespaces || [])] : [],
|
|
716
737
|
missingRequires: capabilityMissingRequires(id, contextDir), compatibility, trust, executable,
|
|
717
738
|
retirement: trust.trusted ? manifest.retirement : undefined,
|
|
718
739
|
_scope: top.scope,
|
|
@@ -725,6 +746,8 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
725
746
|
export function resolveOatsConfig(contextDir, soulName) {
|
|
726
747
|
const chain = configChain(contextDir);
|
|
727
748
|
const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
|
|
749
|
+
const yoloCfg = chain.find((c) => c.yolo !== undefined);
|
|
750
|
+
if (yoloCfg) out.yolo = yoloCfg.yolo;
|
|
728
751
|
// Closest team: declaration wins; the declaring scope is the deployment/team boundary.
|
|
729
752
|
const teamCfg = chain.find((c) => c.team);
|
|
730
753
|
if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
|
|
@@ -883,6 +906,18 @@ function validateCapabilityManifest(m, mf) {
|
|
|
883
906
|
const vendor = id.match(/^([a-z][a-z0-9]*)\./)?.[1];
|
|
884
907
|
if (!vendor) throw new Error(`capability ${id} must use a lowercase dotted vendor ID to declare launch environment`);
|
|
885
908
|
const prefix = `${vendor.toUpperCase()}_`;
|
|
909
|
+
// A capability may declare additional namespaces it speaks for, e.g.
|
|
910
|
+
// the official oats.aweb integration setting the aweb extensions'
|
|
911
|
+
// AWEB_ variables. Declared, never implied: the names are disclosed at
|
|
912
|
+
// trust time with the rest of the launch environment, and the reserved
|
|
913
|
+
// core and bootstrap namespaces can never be claimed this way.
|
|
914
|
+
const extraNamespaces = m.environmentNamespaces === undefined ? [] : m.environmentNamespaces;
|
|
915
|
+
if (!Array.isArray(extraNamespaces) || extraNamespaces.some((ns) => typeof ns !== "string")) throw new Error(`capability ${id} manifest environmentNamespaces must be an array of prefixes`);
|
|
916
|
+
for (const ns of extraNamespaces) {
|
|
917
|
+
if (!/^[A-Z][A-Z0-9]*_$/.test(ns)) throw new Error(`capability ${id} manifest environmentNamespaces entry ${JSON.stringify(ns)} must be an uppercase prefix ending in an underscore`);
|
|
918
|
+
if (ns === "OATS_" || ns === "PI_AGENT_" || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => ns.startsWith(reserved) || reserved.startsWith(ns))) throw new Error(`capability ${id} manifest environmentNamespaces entry ${ns} is a reserved namespace`);
|
|
919
|
+
}
|
|
920
|
+
const allowed = [prefix, ...extraNamespaces];
|
|
886
921
|
for (const name of m.environment) {
|
|
887
922
|
if (!PORTABLE_ENV_NAME_RE.test(name)) throw new Error(`capability ${id} manifest environment name ${JSON.stringify(name)} is invalid`);
|
|
888
923
|
if (CORE_LAUNCH_ENV.has(name) || name.startsWith("OATS_") || name.startsWith("PI_AGENT_")) {
|
|
@@ -891,7 +926,7 @@ function validateCapabilityManifest(m, mf) {
|
|
|
891
926
|
if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
|
|
892
927
|
throw new Error(`capability ${id} manifest environment name ${name} collides with a reserved process bootstrap variable`);
|
|
893
928
|
}
|
|
894
|
-
if (!name.startsWith(
|
|
929
|
+
if (!allowed.some((ns) => name.startsWith(ns))) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""} (declare another in environmentNamespaces)`);
|
|
895
930
|
}
|
|
896
931
|
}
|
|
897
932
|
}
|
|
@@ -1303,6 +1338,7 @@ export function capabilityTrust(a, b) {
|
|
|
1303
1338
|
commands: Object.keys(manifest?.commands || {}),
|
|
1304
1339
|
hooks: Object.keys(manifest?.hooks || {}),
|
|
1305
1340
|
environment: [...(manifest?.environment || [])],
|
|
1341
|
+
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
1306
1342
|
};
|
|
1307
1343
|
return { ...t, package: t.package || manifest?._package, executableSurface: surface };
|
|
1308
1344
|
}
|
|
@@ -2585,6 +2621,7 @@ function executableSurfaceOf(manifest) {
|
|
|
2585
2621
|
commands: Object.keys(manifest?.commands || {}),
|
|
2586
2622
|
hooks: Object.keys(manifest?.hooks || {}),
|
|
2587
2623
|
environment: [...(manifest?.environment || [])],
|
|
2624
|
+
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
2588
2625
|
};
|
|
2589
2626
|
}
|
|
2590
2627
|
function hasExecutableSurface(manifest) {
|
|
@@ -4248,6 +4285,27 @@ export function packageSpecIdentity(spec) {
|
|
|
4248
4285
|
/** What the runtime reports about a required package: is it there, where did it
|
|
4249
4286
|
* land, and does the user's entry filter its resources? A settings row alone is
|
|
4250
4287
|
* NOT proof the capability's extension will load (reviewer-8518c49). */
|
|
4288
|
+
/** The installed version of a runtime package: neither pi's nor Claude's
|
|
4289
|
+
* listing reports one, so it is read from the package.json under the
|
|
4290
|
+
* install directory the listing names; undefined when there is none. */
|
|
4291
|
+
export function installedRuntimePackageVersion(runtime, spec, status) {
|
|
4292
|
+
const dir = status?.dir;
|
|
4293
|
+
if (dir && existsSync(join(dir, "package.json"))) {
|
|
4294
|
+
try {
|
|
4295
|
+
const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
|
|
4296
|
+
if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) return v.match(/^\d+\.\d+\.\d+/)[0];
|
|
4297
|
+
} catch { /* unreadable manifest: unknowable */ }
|
|
4298
|
+
}
|
|
4299
|
+
return undefined;
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
export function compareVersionTriples(a, b) {
|
|
4303
|
+
const pa = String(a).split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
4304
|
+
const pb = String(b).split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
4305
|
+
for (let i = 0; i < 3; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
|
|
4306
|
+
return 0;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4251
4309
|
export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}) {
|
|
4252
4310
|
const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
|
|
4253
4311
|
if (!mgr) return { installed: false };
|
|
@@ -4336,6 +4394,12 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4336
4394
|
throw new HookEnvironmentContractError(`${capabilityID} hook env requires a dotted lowercase alphanumeric vendor component; hyphen, @, and / forms cannot claim an environment namespace`);
|
|
4337
4395
|
}
|
|
4338
4396
|
const prefix = `${vendorComponent.toUpperCase()}_`;
|
|
4397
|
+
// The runtime half of the same contract the manifest validator enforces:
|
|
4398
|
+
// a hook may set names under its vendor prefix or under a namespace its
|
|
4399
|
+
// trusted manifest declared (environmentNamespaces), and only names the
|
|
4400
|
+
// manifest listed. Manifest and runtime must permit exactly the same set.
|
|
4401
|
+
const declared = declarations.get(capabilityID) || { names: new Set(), namespaces: [] };
|
|
4402
|
+
const allowed = [prefix, ...(declared.namespaces || [])];
|
|
4339
4403
|
const accepted = {};
|
|
4340
4404
|
for (const name of Object.keys(value).sort()) {
|
|
4341
4405
|
const envValue = value[name];
|
|
@@ -4348,10 +4412,10 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4348
4412
|
if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
|
|
4349
4413
|
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} collides with a reserved process bootstrap variable`);
|
|
4350
4414
|
}
|
|
4351
|
-
if (!name.startsWith(
|
|
4352
|
-
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${
|
|
4415
|
+
if (!allowed.some((ns) => name.startsWith(ns))) {
|
|
4416
|
+
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""}`);
|
|
4353
4417
|
}
|
|
4354
|
-
if (!
|
|
4418
|
+
if (!declared.names.has(name)) {
|
|
4355
4419
|
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is not declared in its trusted manifest environment`);
|
|
4356
4420
|
}
|
|
4357
4421
|
if (typeof envValue !== "string") {
|
|
@@ -4376,7 +4440,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4376
4440
|
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
|
|
4377
4441
|
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
|
|
4378
4442
|
const envOwners = new Map();
|
|
4379
|
-
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, new Set(cap.environment || [])]));
|
|
4443
|
+
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
|
|
4380
4444
|
const caps = [...(resolved.capabilities || [])];
|
|
4381
4445
|
if (event === "retire") caps.reverse();
|
|
4382
4446
|
for (const cap of caps) {
|
|
@@ -4716,13 +4780,22 @@ function runSoulScaffoldHooks(args) {
|
|
|
4716
4780
|
if (Object.keys(owners).length) writeFileSync(ownersFile, JSON.stringify(owners, null, 2) + "\n");
|
|
4717
4781
|
}
|
|
4718
4782
|
|
|
4719
|
-
|
|
4783
|
+
/** Flat soul YAML uses strings; never treat the string "false" as truthy. */
|
|
4784
|
+
export function resolveYolo(value) {
|
|
4785
|
+
if (value === undefined) return undefined;
|
|
4786
|
+
if (value === true || value === "true") return true;
|
|
4787
|
+
if (value === false || value === "false") return false;
|
|
4788
|
+
throw new Error("yolo must be true or false");
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo, description, type, instructions }) {
|
|
4792
|
+
yolo = resolveYolo(yolo);
|
|
4720
4793
|
const agentDir = agentDirOf(root, name, kind);
|
|
4721
4794
|
const soulDir = soulOf(agentDir);
|
|
4722
4795
|
mkdirSync(soulDir, { recursive: true });
|
|
4723
4796
|
mkdirSync(join(agentDir, "instances"), { recursive: true });
|
|
4724
4797
|
writeFileSync(join(soulDir, "soul.yaml"), yamlFlat({
|
|
4725
|
-
name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model,
|
|
4798
|
+
name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model, yolo,
|
|
4726
4799
|
}));
|
|
4727
4800
|
const agentsMd = join(soulDir, "AGENTS.md");
|
|
4728
4801
|
if (instructions !== undefined || !existsSync(agentsMd)) {
|
|
@@ -4767,7 +4840,7 @@ export function createAgent(root, o) {
|
|
|
4767
4840
|
* Local souls are full souls — same scaffold and memory as persistent ones —
|
|
4768
4841
|
* that live in the scope's uncommitted local-agents/. */
|
|
4769
4842
|
export function upsertLocalAgent(root, o) {
|
|
4770
|
-
let { name, instructions, description, model, repo, work, runtime } = o;
|
|
4843
|
+
let { name, instructions, description, model, repo, work, runtime, yolo } = o;
|
|
4771
4844
|
if (o.file) {
|
|
4772
4845
|
const f = resolve(o.file);
|
|
4773
4846
|
if (!existsSync(f)) throw new Error(`file not found: ${f}`);
|
|
@@ -4778,6 +4851,7 @@ export function upsertLocalAgent(root, o) {
|
|
|
4778
4851
|
repo = repo ?? meta.repo;
|
|
4779
4852
|
work = work ?? meta.work;
|
|
4780
4853
|
runtime = runtime ?? meta.runtime;
|
|
4854
|
+
yolo = yolo ?? meta.yolo;
|
|
4781
4855
|
instructions = body;
|
|
4782
4856
|
}
|
|
4783
4857
|
if (!name) throw new Error("local agent requires a name");
|
|
@@ -4789,7 +4863,7 @@ export function upsertLocalAgent(root, o) {
|
|
|
4789
4863
|
writeSoul(root, {
|
|
4790
4864
|
name, kind: "local",
|
|
4791
4865
|
repo: repo ?? existing?.repo, work: work ?? existing?.work,
|
|
4792
|
-
runtime: runtime ?? existing?.runtime, model: model ?? existing?.model,
|
|
4866
|
+
runtime: runtime ?? existing?.runtime, model: model ?? existing?.model, yolo: yolo ?? existing?.yolo,
|
|
4793
4867
|
description: description ?? existing?.description, instructions,
|
|
4794
4868
|
});
|
|
4795
4869
|
return findAgent(root, name);
|
|
@@ -4909,9 +4983,19 @@ export function resolveClaudeBinary(contextDir) {
|
|
|
4909
4983
|
* pi: checked against `pi --list-models <pattern>` (authenticated providers).
|
|
4910
4984
|
* claude: pi-style patterns are translated (anthropic/<id> → <id>) or dropped —
|
|
4911
4985
|
* claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
|
|
4912
|
-
*
|
|
4986
|
+
* codex: translate openai/openai-codex entries to native ids, otherwise use its default.
|
|
4987
|
+
* Probe failures: first entry wins (pi errors loudly at launch). */
|
|
4913
4988
|
export function resolveModelPreference(model, runtime = "pi") {
|
|
4914
4989
|
const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
4990
|
+
if (runtime === "codex") {
|
|
4991
|
+
for (const pref of prefs) {
|
|
4992
|
+
const bare = pref.replace(/:[a-z]+$/i, "");
|
|
4993
|
+
if (!bare.includes("/")) return bare;
|
|
4994
|
+
const [provider, ...rest] = bare.split("/");
|
|
4995
|
+
if (["openai", "openai-codex"].includes(provider) && rest.length) return rest.join("/");
|
|
4996
|
+
}
|
|
4997
|
+
return ""; // let Codex use its configured model rather than another provider's id
|
|
4998
|
+
}
|
|
4915
4999
|
if (runtime === "claude") {
|
|
4916
5000
|
// Claude accepts its aliases and bare claude-* ids — NOT pi-style
|
|
4917
5001
|
// "provider/model[:thinking]" patterns. Agents whose soul default is a
|
|
@@ -4973,6 +5057,11 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
4973
5057
|
for (const cap of resolved.capabilities || []) {
|
|
4974
5058
|
for (const raw of cap.manifest?.requires || []) {
|
|
4975
5059
|
if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
|
|
5060
|
+
// A requirement may be conditional on the capability's effective
|
|
5061
|
+
// settings (`when: { delivery: "channel" }`): rows whose condition does
|
|
5062
|
+
// not hold are not requirements of this spawn at all.
|
|
5063
|
+
if (raw.when !== undefined && (!raw.when || typeof raw.when !== "object" || Array.isArray(raw.when))) { problems.push(`${cap.id}: a requirement's \`when\` must be an object of setting names to values`); continue; }
|
|
5064
|
+
if (raw.when && !Object.entries(raw.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v))) continue;
|
|
4976
5065
|
const spec = raw.package;
|
|
4977
5066
|
if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
|
|
4978
5067
|
if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
|
|
@@ -4981,7 +5070,10 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
4981
5070
|
const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
|
|
4982
5071
|
const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
|
|
4983
5072
|
const remedy = `run \`oats install --accept-requirement ${runtime}:${runtimePackageIdentity(runtime, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
|
|
4984
|
-
|
|
5073
|
+
// `ifInstalled: true`: the row constrains a package that may be absent
|
|
5074
|
+
// (an ambient extension must honour a contract IF it is there); absence
|
|
5075
|
+
// satisfies it. Without the flag, absence fails as before.
|
|
5076
|
+
if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
|
|
4985
5077
|
// A settings row is not proof the extension loads. Both of these leave the
|
|
4986
5078
|
// capability silently absent, which is the loss this gate exists to stop.
|
|
4987
5079
|
if (status.unverified) { problems.push(`${cap.id} requires the ${runtime} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
|
|
@@ -4993,6 +5085,16 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
4993
5085
|
}
|
|
4994
5086
|
if (status.disabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || runtime} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
|
|
4995
5087
|
if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
|
|
5088
|
+
// A floor on the installed version (`minVersion`), checked once presence
|
|
5089
|
+
// and loadability are settled: a package whose manifest states an older
|
|
5090
|
+
// version, or none, fails closed with the same remedy, since an old
|
|
5091
|
+
// extension that ignores a newer contract (AWEB_DELIVERY) is exactly
|
|
5092
|
+
// what the floor guards.
|
|
5093
|
+
if (raw.minVersion) {
|
|
5094
|
+
const have = installedRuntimePackageVersion(runtime, spec, status);
|
|
5095
|
+
if (!have) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
|
|
5096
|
+
if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
|
|
5097
|
+
}
|
|
4996
5098
|
if (status.extensionsFilter?.length) {
|
|
4997
5099
|
// Unverifiable, not merely auditable: proving the filter selects this
|
|
4998
5100
|
// capability's extension means implementing pi's glob semantics, and
|
|
@@ -5017,8 +5119,12 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5017
5119
|
if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
|
|
5018
5120
|
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
5121
|
const runtime = o.runtime || agent.runtime || "pi";
|
|
5122
|
+
if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
|
|
5020
5123
|
const model = resolveModelPreference(o.model || agent.model || "", runtime);
|
|
5021
5124
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
5125
|
+
const backend = o.backend || agent.backend || "tmux";
|
|
5126
|
+
if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
|
|
5127
|
+
if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
|
|
5022
5128
|
const launch = o.launch !== false;
|
|
5023
5129
|
const repoAbs = resolveRepo(root, o.repo || agent.repo);
|
|
5024
5130
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
@@ -5324,6 +5430,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5324
5430
|
const soulDir = agent._soulDir || soulOf(agent._dir);
|
|
5325
5431
|
const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
|
|
5326
5432
|
const resolvedCfg = composition.resolved;
|
|
5433
|
+
const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
|
|
5327
5434
|
const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
|
|
5328
5435
|
// Runtime extensions selected by ACTIVE capabilities for THIS instance's
|
|
5329
5436
|
// runtime. Strict launch disables ambient extension discovery, so each one has
|
|
@@ -5335,9 +5442,10 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5335
5442
|
|
|
5336
5443
|
// Prerequisites must fail before creating a home, worktree, or identity.
|
|
5337
5444
|
const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
|
|
5338
|
-
const bin = which(runtime === "claude" ? claudeBin :
|
|
5445
|
+
const bin = which(runtime === "claude" ? claudeBin : runtime);
|
|
5339
5446
|
if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
|
|
5340
|
-
if (launch && !which(
|
|
5447
|
+
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
5448
|
+
const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
5341
5449
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5342
5450
|
|
|
5343
5451
|
mkdirSync(home, { recursive: true });
|
|
@@ -5524,6 +5632,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5524
5632
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
5525
5633
|
let windowMayExist = false;
|
|
5526
5634
|
let spawnTmux;
|
|
5635
|
+
let spawnHerdr;
|
|
5527
5636
|
const ancillaryCleanup = [];
|
|
5528
5637
|
// One compensation owner, from the first hook result through launch and
|
|
5529
5638
|
// the final lineage write. Preserve the original failure and retain any
|
|
@@ -5543,7 +5652,23 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5543
5652
|
const outstandingGit = new Set();
|
|
5544
5653
|
// A failed new-window command may still have created its window. Verify
|
|
5545
5654
|
// quiescence before removing credentials or work that runtime may be using.
|
|
5546
|
-
if (windowMayExist) {
|
|
5655
|
+
if (windowMayExist && spawnHerdr) {
|
|
5656
|
+
try { stopHerdr(spawnHerdr); }
|
|
5657
|
+
catch (e) {
|
|
5658
|
+
incomplete.push(`Herdr session: ${e.message}`);
|
|
5659
|
+
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5660
|
+
if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
|
|
5661
|
+
return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
5662
|
+
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
5663
|
+
launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
|
|
5664
|
+
}
|
|
5665
|
+
}
|
|
5666
|
+
if (windowMayExist && backend === "herdr" && !spawnHerdr) {
|
|
5667
|
+
// Allocation starts only an empty shell; the harness command has not run.
|
|
5668
|
+
// A lost receipt cannot authorize closing an unidentified terminal.
|
|
5669
|
+
incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
|
|
5670
|
+
}
|
|
5671
|
+
if (windowMayExist && backend === "tmux") {
|
|
5547
5672
|
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
5548
5673
|
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
5549
5674
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
@@ -5655,7 +5780,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5655
5780
|
You are instance "${instance}" of agent "${agent.name}".
|
|
5656
5781
|
- Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
|
|
5657
5782
|
- Work tree: ./work — ${workDesc}
|
|
5658
|
-
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
|
|
5783
|
+
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${runtime === "codex" ? "\n## Runtime notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
|
|
5659
5784
|
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
|
|
5660
5785
|
|
|
5661
5786
|
// Launch command. Spawn IS session start: this command is persisted in
|
|
@@ -5672,7 +5797,18 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5672
5797
|
// the TASK.md text is swallowed as that flag's next value — claude
|
|
5673
5798
|
// errors out ("entries must be tagged: <task text>") and the window
|
|
5674
5799
|
// drops to the fallback shell, which reads as a silently stuck spawn.
|
|
5675
|
-
cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5800
|
+
cmdline = `${shq(bin)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5801
|
+
} else if (runtime === "codex") {
|
|
5802
|
+
// Codex discovers this home's AGENTS.md and .agents/skills natively.
|
|
5803
|
+
// Keep the operator's native permission policy. --add-dir is not valid
|
|
5804
|
+
// under every policy (including untrusted/read-only startup). Worktrees
|
|
5805
|
+
// already live below home; external paths use native approval handling.
|
|
5806
|
+
// --yolo bypasses execution approvals but Codex still asks to trust a new
|
|
5807
|
+
// project. The operator's explicit yolo choice also trusts this generated
|
|
5808
|
+
// home for this launch, without editing the shared user config.
|
|
5809
|
+
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
5810
|
+
cmdline = `${shq(bin)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}`
|
|
5811
|
+
+ `${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5676
5812
|
} else {
|
|
5677
5813
|
// STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
|
|
5678
5814
|
// or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
|
|
@@ -5729,6 +5865,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5729
5865
|
const meta = {
|
|
5730
5866
|
agent: agent.name, kind: agent.kind || "persistent", instance, home,
|
|
5731
5867
|
repo: repoAbs, work, branch, runtime, model: model || undefined,
|
|
5868
|
+
...(yolo !== undefined ? { yolo } : {}),
|
|
5732
5869
|
team: resolvedCfg.team || undefined,
|
|
5733
5870
|
parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
|
|
5734
5871
|
siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
|
|
@@ -5742,6 +5879,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5742
5879
|
settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
|
|
5743
5880
|
hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
|
|
5744
5881
|
...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
|
|
5882
|
+
...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
|
|
5745
5883
|
})),
|
|
5746
5884
|
skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
|
|
5747
5885
|
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
@@ -5768,7 +5906,11 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5768
5906
|
ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
|
|
5769
5907
|
why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
|
|
5770
5908
|
}
|
|
5771
|
-
: {
|
|
5909
|
+
: runtime === "codex" ? {
|
|
5910
|
+
oatsComposed: "skills via .agents/skills; instructions via AGENTS.md; task via initial prompt",
|
|
5911
|
+
ambient: ["user and ancestor instructions", "user, project, admin and system skills", "user and project configuration and MCP servers"],
|
|
5912
|
+
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.",
|
|
5913
|
+
} : {
|
|
5772
5914
|
oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
|
|
5773
5915
|
curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
|
|
5774
5916
|
ambient: ["globally configured pi extensions, and any resources they contribute"],
|
|
@@ -5780,17 +5922,25 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5780
5922
|
},
|
|
5781
5923
|
capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
|
|
5782
5924
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
5783
|
-
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
5925
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
5784
5926
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
5785
5927
|
executable: cap.executable,
|
|
5786
5928
|
})),
|
|
5787
|
-
tmux: { session, window: instance },
|
|
5929
|
+
...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
|
|
5788
5930
|
command: cmdline, createdAt: new Date().toISOString(),
|
|
5789
5931
|
};
|
|
5790
5932
|
const spawnWarnings = warnings;
|
|
5791
5933
|
|
|
5792
5934
|
spawnTmux = meta.tmux;
|
|
5793
|
-
if (launch) {
|
|
5935
|
+
if (launch && backend === "herdr") {
|
|
5936
|
+
windowMayExist = true;
|
|
5937
|
+
spawnHerdr = allocateHerdr(herdrBase, { home, instance });
|
|
5938
|
+
meta.sessionTarget = spawnHerdr;
|
|
5939
|
+
meta.launched = true;
|
|
5940
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5941
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
|
|
5942
|
+
launchHerdr(spawnHerdr, cmdline);
|
|
5943
|
+
} else if (launch) {
|
|
5794
5944
|
if (!tmuxAlive(session)) {
|
|
5795
5945
|
const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
|
|
5796
5946
|
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
@@ -5844,7 +5994,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5844
5994
|
}
|
|
5845
5995
|
}
|
|
5846
5996
|
|
|
5847
|
-
return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
5997
|
+
return { ...meta, attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
5848
5998
|
} catch (error) {
|
|
5849
5999
|
const note = compensateSpawn();
|
|
5850
6000
|
error.message += note;
|
|
@@ -5856,8 +6006,11 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5856
6006
|
const windows = tmuxWindows(tmuxSession);
|
|
5857
6007
|
const readInstancesOf = (agentDir) => {
|
|
5858
6008
|
const instancesDir = join(agentDir, "instances");
|
|
6009
|
+
// An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
|
|
6010
|
+
// dot-directory under instances/ is kernel bookkeeping (.oats-retirement
|
|
6011
|
+
// holds baselines and recoveries), never a home (oats-5xl).
|
|
5859
6012
|
return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
|
|
5860
|
-
.filter((e) => e.isDirectory())
|
|
6013
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
5861
6014
|
.map((e) => {
|
|
5862
6015
|
const metaPath = join(instancesDir, e.name, "instance.json");
|
|
5863
6016
|
const home = join(instancesDir, e.name);
|
|
@@ -5872,12 +6025,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5872
6025
|
try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
|
|
5873
6026
|
catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
|
|
5874
6027
|
}
|
|
5875
|
-
|
|
6028
|
+
// A self-retire that has been requested but not completed: the home is
|
|
6029
|
+
// owed a retirement, not live work. Read-only here — completion is the
|
|
6030
|
+
// detached child's or an operator's `oats retire`, never status.
|
|
6031
|
+
const pending = retirePendingMarkerPath(home);
|
|
6032
|
+
let retirePending;
|
|
6033
|
+
if (existsSync(pending)) {
|
|
6034
|
+
try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
|
|
6035
|
+
catch { retirePending = { reason: "retire pending" }; }
|
|
6036
|
+
}
|
|
6037
|
+
let liveness = { running: windows.includes(meta.instance || e.name) };
|
|
6038
|
+
if (meta.sessionTarget) {
|
|
6039
|
+
try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
|
|
6040
|
+
catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
|
|
6041
|
+
}
|
|
6042
|
+
return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
|
|
6043
|
+
|
|
5876
6044
|
});
|
|
5877
6045
|
};
|
|
6046
|
+
// Failed deferred self-retirements leave their outcome beside the home; a
|
|
6047
|
+
// successful one is evidence only and is not a problem to surface.
|
|
6048
|
+
const readRetireFailuresOf = (agentDir) => {
|
|
6049
|
+
const instancesDir = join(agentDir, "instances");
|
|
6050
|
+
if (!existsSync(instancesDir)) return [];
|
|
6051
|
+
const out = [];
|
|
6052
|
+
for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
|
|
6053
|
+
const m = e.isFile() && /^\.oats-retired-(.+)\.json$/.exec(e.name);
|
|
6054
|
+
if (!m) continue;
|
|
6055
|
+
try {
|
|
6056
|
+
const r = JSON.parse(readFileSync(join(instancesDir, e.name), "utf8"));
|
|
6057
|
+
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) });
|
|
6058
|
+
} catch { out.push({ instance: m[1], error: "unreadable result file", resultPath: join(instancesDir, e.name) }); }
|
|
6059
|
+
}
|
|
6060
|
+
return out;
|
|
6061
|
+
};
|
|
6062
|
+
const withFailures = (entry, agentDir) => {
|
|
6063
|
+
const retireFailures = readRetireFailuresOf(agentDir);
|
|
6064
|
+
return retireFailures.length ? { ...entry, retireFailures } : entry;
|
|
6065
|
+
};
|
|
5878
6066
|
const out = listAgents(root).map((a) => {
|
|
5879
6067
|
const { _dir, ...soul } = a;
|
|
5880
|
-
return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
|
|
6068
|
+
return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
|
|
5881
6069
|
});
|
|
5882
6070
|
// Capability-defined agents home under local-agents/<name>/ WITHOUT a local
|
|
5883
6071
|
// soul (it lives read-only in the package) — surface their instances too.
|
|
@@ -5887,9 +6075,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
5887
6075
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
5888
6076
|
if (!e.isDirectory() || seen.has(e.name)) continue;
|
|
5889
6077
|
const instances = readInstancesOf(join(dir, e.name));
|
|
5890
|
-
|
|
6078
|
+
const retireFailures = readRetireFailuresOf(join(dir, e.name));
|
|
6079
|
+
if (!instances.length && !retireFailures.length) continue;
|
|
5891
6080
|
const cap = instances.find((i) => i.capability)?.capability;
|
|
5892
|
-
out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances });
|
|
6081
|
+
out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances, ...(retireFailures.length ? { retireFailures } : {}) });
|
|
5893
6082
|
seen.add(e.name);
|
|
5894
6083
|
}
|
|
5895
6084
|
}
|
|
@@ -5967,7 +6156,7 @@ export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
|
|
|
5967
6156
|
|
|
5968
6157
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
5969
6158
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
5970
|
-
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
|
|
6159
|
+
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
|
|
5971
6160
|
try {
|
|
5972
6161
|
writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
|
|
5973
6162
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
@@ -5983,10 +6172,11 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
5983
6172
|
cleanup: {
|
|
5984
6173
|
version: QUARANTINE_CLEANUP_VERSION,
|
|
5985
6174
|
repo: repoAbs, work, branch, launched, tmux,
|
|
6175
|
+
...(sessionTarget ? { sessionTarget } : {}),
|
|
5986
6176
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
|
|
5987
6177
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
5988
6178
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
5989
|
-
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
6179
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
5990
6180
|
missingRequires: cap.missingRequires,
|
|
5991
6181
|
trust: cap.trust, executable: cap.executable,
|
|
5992
6182
|
})),
|
|
@@ -5997,7 +6187,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
5997
6187
|
} catch { /* the quarantine still stands without its marker */ }
|
|
5998
6188
|
if (recordRetirementBaseline) {
|
|
5999
6189
|
try {
|
|
6000
|
-
writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux });
|
|
6190
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
|
|
6001
6191
|
} catch (e) {
|
|
6002
6192
|
incomplete.push(`independent retirement authority: ${e.message}`);
|
|
6003
6193
|
}
|
|
@@ -6071,6 +6261,15 @@ function retirementBaselinePath(home) {
|
|
|
6071
6261
|
return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
|
|
6072
6262
|
}
|
|
6073
6263
|
|
|
6264
|
+
/** git's output for a large tree (status with untracked and ignored files,
|
|
6265
|
+
* the index listing, a large blob) easily exceeds Node's 1 MiB default child
|
|
6266
|
+
* buffer; the spawn then dies with ENOBUFS and retirement reports the
|
|
6267
|
+
* recovery as unverifiable (cjr, ~9500 tracked long paths). Every git call
|
|
6268
|
+
* on the retirement path gets this bound instead. It raises the practical
|
|
6269
|
+
* limit, it does not remove it: a single blob over 512 MiB would still fail,
|
|
6270
|
+
* loudly, and streaming is deliberately not attempted in this change. */
|
|
6271
|
+
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
6272
|
+
|
|
6074
6273
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
6075
6274
|
const hash = createHash("sha256");
|
|
6076
6275
|
const rootStat = lstatSync(root);
|
|
@@ -6100,7 +6299,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
6100
6299
|
|
|
6101
6300
|
function worktreeStatus(repo) {
|
|
6102
6301
|
try {
|
|
6103
|
-
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
6302
|
+
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6104
6303
|
} catch (e) {
|
|
6105
6304
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
|
|
6106
6305
|
}
|
|
@@ -6151,6 +6350,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
|
|
|
6151
6350
|
runtime: {
|
|
6152
6351
|
launched: runtime?.launched === true,
|
|
6153
6352
|
...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
|
|
6353
|
+
...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
|
|
6154
6354
|
},
|
|
6155
6355
|
};
|
|
6156
6356
|
const path = retirementBaselinePath(home);
|
|
@@ -6176,7 +6376,7 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6176
6376
|
if (!repo || !branch) return [];
|
|
6177
6377
|
const target = `refs/heads/${branch}`;
|
|
6178
6378
|
try {
|
|
6179
|
-
execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6379
|
+
execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6180
6380
|
} catch (e) {
|
|
6181
6381
|
const detail = String(e.stderr ?? "").trim();
|
|
6182
6382
|
// A quarantine retry may follow a successful rollback-owned branch removal.
|
|
@@ -6185,9 +6385,9 @@ function branchOnlyCommits(repo, branch) {
|
|
|
6185
6385
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6186
6386
|
}
|
|
6187
6387
|
try {
|
|
6188
|
-
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
6388
|
+
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
|
|
6189
6389
|
.split("\n").filter((ref) => ref && ref !== target);
|
|
6190
|
-
return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").filter(Boolean);
|
|
6390
|
+
return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim().split("\n").filter(Boolean);
|
|
6191
6391
|
} catch (e) {
|
|
6192
6392
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
|
|
6193
6393
|
}
|
|
@@ -6197,11 +6397,55 @@ function runtimeAuthorityOf(baseline) {
|
|
|
6197
6397
|
const runtime = baseline?.runtime;
|
|
6198
6398
|
if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
|
|
6199
6399
|
if (!runtime.launched) return { launched: false };
|
|
6400
|
+
if (runtime.sessionTarget !== undefined) {
|
|
6401
|
+
if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
|
|
6402
|
+
return { launched: true, sessionTarget: runtime.sessionTarget };
|
|
6403
|
+
}
|
|
6200
6404
|
const tmux = runtime.tmux;
|
|
6201
6405
|
if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
|
|
6202
6406
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
6203
6407
|
}
|
|
6204
6408
|
|
|
6409
|
+
/** Session control uses the same independent endpoint receipt as retirement. */
|
|
6410
|
+
function instanceSessionTarget(home) {
|
|
6411
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
6412
|
+
home = realPathOrNearest(home);
|
|
6413
|
+
let baseline, meta;
|
|
6414
|
+
try {
|
|
6415
|
+
baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
|
|
6416
|
+
meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
|
|
6417
|
+
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
6418
|
+
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
6419
|
+
if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
|
|
6420
|
+
const endpointAgrees = authority.sessionTarget
|
|
6421
|
+
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
|
|
6422
|
+
: !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
6423
|
+
if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
6424
|
+
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
|
|
6425
|
+
}
|
|
6426
|
+
|
|
6427
|
+
export function inspectInstanceSession(home) {
|
|
6428
|
+
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
|
|
6429
|
+
const s = instanceSessionTarget(home);
|
|
6430
|
+
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
|
|
6431
|
+
try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
|
|
6432
|
+
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
6433
|
+
}
|
|
6434
|
+
|
|
6435
|
+
export async function attachInstanceSession(home) {
|
|
6436
|
+
const s = instanceSessionTarget(home);
|
|
6437
|
+
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
6438
|
+
return attachSessionTarget(s.target);
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
export function inputInstanceSession(home, text) {
|
|
6442
|
+
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");
|
|
6443
|
+
const s = instanceSessionTarget(home);
|
|
6444
|
+
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
6445
|
+
try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
|
|
6446
|
+
catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
|
|
6447
|
+
}
|
|
6448
|
+
|
|
6205
6449
|
function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
|
|
6206
6450
|
const classes = [];
|
|
6207
6451
|
let baseline;
|
|
@@ -6247,17 +6491,44 @@ const RECOVERABLE_GIT_ADMIN = [
|
|
|
6247
6491
|
"BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
|
|
6248
6492
|
];
|
|
6249
6493
|
|
|
6494
|
+
/** Object ids among `oids` that `repo` does not have, from one
|
|
6495
|
+
* `cat-file --batch-check` process. */
|
|
6496
|
+
export function missingGitObjects(repo, oids) {
|
|
6497
|
+
if (!oids.length) return [];
|
|
6498
|
+
const out = execFileSync("git", ["-C", repo, "cat-file", "--batch-check=%(objectname) %(objecttype)"], { input: oids.join("\n") + "\n", encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
|
|
6499
|
+
const missing = [];
|
|
6500
|
+
for (const line of out.split("\n")) {
|
|
6501
|
+
if (!line.endsWith(" missing")) continue;
|
|
6502
|
+
missing.push(line.slice(0, line.indexOf(" ")));
|
|
6503
|
+
}
|
|
6504
|
+
return missing;
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6250
6507
|
function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
6251
|
-
const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim();
|
|
6508
|
+
const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6252
6509
|
const recoveredGit = join(recoveredRepo, ".git");
|
|
6253
|
-
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"]);
|
|
6510
|
+
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
|
|
6511
|
+
// The staged blobs the recovered index will point at. The clone already
|
|
6512
|
+
// holds every blob reachable from a commit; only content that is staged but
|
|
6513
|
+
// never committed is missing, so ask once which objects the recovered
|
|
6514
|
+
// repository lacks (one process for the whole index) and copy only those.
|
|
6515
|
+
// Copying every row cost two Git processes per index entry: a clean 9,500
|
|
6516
|
+
// file tree took ~19,000 launches and looked like a hang. Gitlink rows
|
|
6517
|
+
// (mode 160000) name commits of nested repositories, which
|
|
6518
|
+
// materializeNestedRepositories restores; they are never blobs here.
|
|
6519
|
+
const staged = new Set();
|
|
6254
6520
|
for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
|
|
6255
|
-
const match = row.match(
|
|
6256
|
-
if (!match) continue;
|
|
6257
|
-
|
|
6258
|
-
const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" }).trim();
|
|
6259
|
-
if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
|
|
6521
|
+
const match = row.match(/^(\d+) ([0-9a-f]+) \d+\t/);
|
|
6522
|
+
if (!match || match[1] === "160000") continue;
|
|
6523
|
+
staged.add(match[2]);
|
|
6260
6524
|
}
|
|
6525
|
+
for (const oid of missingGitObjects(recoveredRepo, [...staged])) {
|
|
6526
|
+
const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", oid], { maxBuffer: GIT_MAX_BUFFER });
|
|
6527
|
+
const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6528
|
+
if (restored !== oid) throw new Error(`recovered Git object ${restored} did not match source ${oid}`);
|
|
6529
|
+
}
|
|
6530
|
+
const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
|
|
6531
|
+
if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
|
|
6261
6532
|
copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
|
|
6262
6533
|
for (const name of RECOVERABLE_GIT_ADMIN) {
|
|
6263
6534
|
const source = join(sourceGit, name);
|
|
@@ -6270,11 +6541,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
|
6270
6541
|
|
|
6271
6542
|
function detachRecoveryClone(source, recovered) {
|
|
6272
6543
|
let stash;
|
|
6273
|
-
try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
6544
|
+
try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
|
|
6274
6545
|
catch { stash = undefined; }
|
|
6275
6546
|
if (stash) {
|
|
6276
|
-
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"]);
|
|
6277
|
-
const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim();
|
|
6547
|
+
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
|
|
6548
|
+
const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6278
6549
|
const stashLog = join(sourceCommon, "logs", "refs", "stash");
|
|
6279
6550
|
if (existsSync(stashLog)) {
|
|
6280
6551
|
const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
|
|
@@ -6282,17 +6553,17 @@ function detachRecoveryClone(source, recovered) {
|
|
|
6282
6553
|
copyFileSync(stashLog, recoveredLog);
|
|
6283
6554
|
}
|
|
6284
6555
|
}
|
|
6285
|
-
try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" }); } catch { /* no remote is already independent */ }
|
|
6556
|
+
try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" , maxBuffer: GIT_MAX_BUFFER }); } catch { /* no remote is already independent */ }
|
|
6286
6557
|
}
|
|
6287
6558
|
|
|
6288
6559
|
function materializeNestedRepositories(sourceWork, recoveredRepo) {
|
|
6289
6560
|
for (const source of nestedGitRoots(sourceWork)) {
|
|
6290
6561
|
const rel = relative(sourceWork, source);
|
|
6291
6562
|
const dest = join(recoveredRepo, rel);
|
|
6292
|
-
const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
6563
|
+
const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6293
6564
|
rmSync(dest, { recursive: true, force: true });
|
|
6294
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6295
|
-
execFileSync("git", ["-C", dest, "checkout", "--quiet", head]);
|
|
6565
|
+
execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6566
|
+
execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
|
|
6296
6567
|
detachRecoveryClone(source, dest);
|
|
6297
6568
|
restoreStandaloneGitState(source, dest);
|
|
6298
6569
|
for (const e of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -6312,14 +6583,24 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6312
6583
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
6313
6584
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
6314
6585
|
try {
|
|
6586
|
+
// A home-only change (notes, runtime files, credentials) needs a home
|
|
6587
|
+
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
6588
|
+
// In-progress Git operations retain the full standalone recovery even
|
|
6589
|
+
// when porcelain status has no changed paths.
|
|
6590
|
+
let homeOnly = observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
|
|
6591
|
+
&& meta.work === "worktree" && existsSync(observation.work);
|
|
6592
|
+
if (homeOnly) {
|
|
6593
|
+
const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6594
|
+
homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
6595
|
+
}
|
|
6315
6596
|
const recoveredHome = join(staging, "home");
|
|
6316
6597
|
copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
|
|
6317
6598
|
if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
|
|
6318
6599
|
throw new Error("home recovery verification disagreed with the source");
|
|
6319
6600
|
}
|
|
6320
|
-
if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6601
|
+
if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6321
6602
|
const recoveredRepo = join(staging, "repo");
|
|
6322
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] });
|
|
6603
|
+
execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6323
6604
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
6324
6605
|
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
6325
6606
|
if (existsSync(observation.work)) {
|
|
@@ -6337,25 +6618,176 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6337
6618
|
if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
|
|
6338
6619
|
if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
|
|
6339
6620
|
}
|
|
6340
|
-
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
6341
|
-
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" }).trim();
|
|
6621
|
+
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6622
|
+
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6342
6623
|
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
6343
6624
|
}
|
|
6344
|
-
|
|
6625
|
+
const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
|
|
6626
|
+
writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
6345
6627
|
mkdirSync(dirname(recovery), { recursive: true });
|
|
6346
6628
|
renameSync(staging, recovery);
|
|
6347
|
-
return { path: recovery, classes: observation.classes };
|
|
6629
|
+
return { path: recovery, classes: observation.classes, ...(repoCopy ? { repoCopy } : {}) };
|
|
6348
6630
|
} catch (e) {
|
|
6349
6631
|
rmSync(staging, { recursive: true, force: true });
|
|
6350
6632
|
throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
|
|
6351
6633
|
}
|
|
6352
6634
|
}
|
|
6353
6635
|
|
|
6636
|
+
/** Marker a self-retiring instance leaves BESIDE its home: the retirement is
|
|
6637
|
+
* requested and owed, and a detached completion is on its way. It is not
|
|
6638
|
+
* written into the home, so the caller changes no instance bytes and the
|
|
6639
|
+
* completion's work inspection sees exactly what the instance left. */
|
|
6640
|
+
export function retirePendingMarkerPath(home) {
|
|
6641
|
+
return join(dirname(home), `.oats-retire-pending-${basename(home)}.json`);
|
|
6642
|
+
}
|
|
6643
|
+
|
|
6644
|
+
/** Where a deferred self-retirement writes its explicit outcome: a FILE beside
|
|
6645
|
+
* the (former) home, so it survives the home's removal and never reads as an
|
|
6646
|
+
* instance directory. */
|
|
6647
|
+
export function deferredRetireResultPath(home) {
|
|
6648
|
+
return join(dirname(home), `.oats-retired-${basename(home)}.json`);
|
|
6649
|
+
}
|
|
6650
|
+
|
|
6651
|
+
const DEFERRED_RETIRE_SCRIPT = `import { completeDeferredRetirement } from ${JSON.stringify(import.meta.url)};
|
|
6652
|
+
process.exitCode = completeDeferredRetirement(JSON.parse(process.env.OATS_RETIRE_INTENT)) ? 0 : 1;`;
|
|
6653
|
+
|
|
6654
|
+
/** Self-retire (aweb-abep): persist intent, then hand the retirement to a
|
|
6655
|
+
* detached process that runs it as an ORDINARY external retirement after the
|
|
6656
|
+
* caller's window has died. Nothing destructive happens in the caller: no
|
|
6657
|
+
* inspection, no hooks, no removal — the runtime is still alive, and the
|
|
6658
|
+
* quiesce rule stays intact. The child owns its own process group so the
|
|
6659
|
+
* tmux window kill (SIGHUP to the pane's group) cannot take it down, and the
|
|
6660
|
+
* caller's instance env is stripped so the child is an external operator,
|
|
6661
|
+
* not another self-retire. The intent travels to the child in its env; the
|
|
6662
|
+
* marker beside the home is the operator-visible promise and is written only
|
|
6663
|
+
* once a completion process exists (reviewer D2). */
|
|
6664
|
+
function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
6665
|
+
const delaySec = o.selfKillDelaySec ?? 8;
|
|
6666
|
+
const marker = retirePendingMarkerPath(found.home);
|
|
6667
|
+
const resultPath = deferredRetireResultPath(found.home);
|
|
6668
|
+
const logPath = resultPath.replace(/\.json$/, ".log");
|
|
6669
|
+
// A second `--self` while the first completion is still on its way must not
|
|
6670
|
+
// start a second, racing retirement: report the one already owed.
|
|
6671
|
+
if (existsSync(marker) && !existsSync(resultPath)) {
|
|
6672
|
+
let prior; try { prior = JSON.parse(readFileSync(marker, "utf8")); } catch { prior = undefined; }
|
|
6673
|
+
if (prior?.resultPath) {
|
|
6674
|
+
return { retired: name, agent: found.agent.name, deferred: true, alreadyScheduled: true, pendingMarker: marker, resultPath: prior.resultPath, logPath, completesInSec: prior.delaySec ?? delaySec, requestedAt: prior.requestedAt };
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
const intent = {
|
|
6678
|
+
instance: name, agent: found.agent.name, root: resolve(root),
|
|
6679
|
+
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
6680
|
+
options: { home: found.home, deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
6681
|
+
};
|
|
6682
|
+
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
6683
|
+
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
6684
|
+
rmSync(resultPath, { force: true });
|
|
6685
|
+
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} --home ${found.home}\``);
|
|
6686
|
+
let fd, child;
|
|
6687
|
+
try {
|
|
6688
|
+
fd = openSync(logPath, "w");
|
|
6689
|
+
child = spawnProcess(process.execPath, ["--input-type=module", "-e", DEFERRED_RETIRE_SCRIPT], {
|
|
6690
|
+
detached: true, stdio: ["ignore", fd, fd], env, cwd: dirname(found.home),
|
|
6691
|
+
});
|
|
6692
|
+
} catch (e) {
|
|
6693
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
6694
|
+
rmSync(logPath, { force: true });
|
|
6695
|
+
throw scheduleFailed(e.message);
|
|
6696
|
+
}
|
|
6697
|
+
closeSync(fd);
|
|
6698
|
+
if (!child.pid) { rmSync(logPath, { force: true }); throw scheduleFailed("no process was created"); }
|
|
6699
|
+
// A spawn failure Node reports asynchronously (EAGAIN, EMFILE) would arrive
|
|
6700
|
+
// after this returns; record it as a failed outcome so status shows the
|
|
6701
|
+
// debt instead of an uncaught exception behind a success message.
|
|
6702
|
+
child.on("error", (e) => {
|
|
6703
|
+
try {
|
|
6704
|
+
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} --home ${found.home}` }, null, 2) + "\n");
|
|
6705
|
+
} catch { /* the marker alone then shows RETIRING; status names its age */ }
|
|
6706
|
+
});
|
|
6707
|
+
child.unref();
|
|
6708
|
+
writeFileSync(marker, JSON.stringify(intent, null, 2) + "\n");
|
|
6709
|
+
return {
|
|
6710
|
+
retired: name, agent: found.agent.name, deferred: true, pendingMarker: marker,
|
|
6711
|
+
resultPath, logPath, completesInSec: delaySec, completionPid: child.pid,
|
|
6712
|
+
};
|
|
6713
|
+
}
|
|
6714
|
+
|
|
6715
|
+
/** Run by the detached child: wait for the caller's window to be gone, then
|
|
6716
|
+
* retire the instance as an ordinary external operator. Returns true only
|
|
6717
|
+
* when the home is actually gone, and then leaves no file behind. A failure
|
|
6718
|
+
* writes an explicit outcome beside the home and leaves the pending marker
|
|
6719
|
+
* (and, after hooks ran, the usual quarantine) in place, so `oats status`
|
|
6720
|
+
* shows the debt and `oats retire <name>` retries and clears it. Accepts the
|
|
6721
|
+
* intent object (the child gets it in its env) or a marker path. */
|
|
6722
|
+
export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
6723
|
+
let intent = intentOrMarkerPath;
|
|
6724
|
+
if (typeof intentOrMarkerPath === "string") {
|
|
6725
|
+
try { intent = JSON.parse(readFileSync(intentOrMarkerPath, "utf8")); }
|
|
6726
|
+
catch (e) { console.error(`deferred retirement: cannot read ${intentOrMarkerPath}: ${e.message}`); return false; }
|
|
6727
|
+
}
|
|
6728
|
+
if (!isPlainObject(intent) || !intent.instance || !intent.root) { console.error("deferred retirement: intent is not usable"); return false; }
|
|
6729
|
+
const record = (payload) => {
|
|
6730
|
+
if (!intent.resultPath) return;
|
|
6731
|
+
try {
|
|
6732
|
+
writeFileSync(intent.resultPath, JSON.stringify({
|
|
6733
|
+
instance: intent.instance, agent: intent.agent, requestedAt: intent.requestedAt,
|
|
6734
|
+
completedAt: new Date().toISOString(), ...payload,
|
|
6735
|
+
}, null, 2) + "\n");
|
|
6736
|
+
} catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
|
|
6737
|
+
};
|
|
6738
|
+
const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
|
|
6739
|
+
if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
|
6740
|
+
let result;
|
|
6741
|
+
try {
|
|
6742
|
+
result = retireInstance(intent.root, intent.instance, {
|
|
6743
|
+
home: intent.options?.home, deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6744
|
+
});
|
|
6745
|
+
} catch (e) {
|
|
6746
|
+
record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
6747
|
+
console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
|
|
6748
|
+
return false;
|
|
6749
|
+
}
|
|
6750
|
+
if (result.rollbackIncomplete) {
|
|
6751
|
+
record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
6752
|
+
console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
|
|
6753
|
+
return false;
|
|
6754
|
+
}
|
|
6755
|
+
if (intent.options?.keepDir) {
|
|
6756
|
+
// The kept home is the one the intent names, never a same-named twin.
|
|
6757
|
+
const retained = intent.options?.home ? { home: intent.options.home } : findInstanceHome(intent.root, intent.instance);
|
|
6758
|
+
if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
|
|
6759
|
+
}
|
|
6760
|
+
// Success leaves nothing beside the home: the home is gone, the marker went
|
|
6761
|
+
// with it, and a kept success record per retired reviewer or harvester would
|
|
6762
|
+
// accumulate forever (reviewer D4). Only failures leave files, and they are
|
|
6763
|
+
// the ones status surfaces and a retry clears.
|
|
6764
|
+
try { rmSync(intent.resultPath.replace(/\.json$/, ".log"), { force: true }); } catch { /* nothing to keep */ }
|
|
6765
|
+
return true;
|
|
6766
|
+
}
|
|
6767
|
+
|
|
6354
6768
|
export function retireInstance(root, name, o = {}) {
|
|
6355
6769
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6770
|
+
// self-retire: the caller IS the instance. Without --keep-dir the whole
|
|
6771
|
+
// retirement is deferred to a detached external completion (below); with
|
|
6772
|
+
// --keep-dir the old in-process path runs and kills the window LAST.
|
|
6773
|
+
const self = o.self === true;
|
|
6774
|
+
// Names are unique per agent dir only: two agents can own an instance of
|
|
6775
|
+
// the same name (dev --purpose foo-1 and agent dev-foo). Retirement is
|
|
6776
|
+
// destructive, so a name that resolves to several homes is refused unless
|
|
6777
|
+
// the caller says which home (--home), and a home is accepted only when it
|
|
6778
|
+
// is one of that name's homes under this root.
|
|
6779
|
+
const matches = findInstanceHomes(root, name);
|
|
6780
|
+
if (!matches.length) throw new Error(`no instance named "${name}"`);
|
|
6781
|
+
const sameHome = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
6782
|
+
let found;
|
|
6783
|
+
if (o.home) {
|
|
6784
|
+
found = matches.find((m) => sameHome(m.home, o.home));
|
|
6785
|
+
if (!found) throw oatsError("E_HOME_MISMATCH", `instance "${name}" has no home at ${o.home} under this agents root (its home${matches.length === 1 ? " is" : "s are"} ${matches.map((m) => m.home).join(", ")})`);
|
|
6786
|
+
} else if (matches.length > 1) {
|
|
6787
|
+
throw oatsError("E_AMBIGUOUS_INSTANCE", `"${name}" names ${matches.length} instances under this agents root (${matches.map((m) => `${m.agent.name}: ${m.home}`).join("; ")}); retire with --home <path> to say which`);
|
|
6788
|
+
} else {
|
|
6789
|
+
found = matches[0];
|
|
6790
|
+
}
|
|
6359
6791
|
const metaPath = join(found.home, "instance.json");
|
|
6360
6792
|
// A QUARANTINED home (spawn failed after a required hook and compensation did
|
|
6361
6793
|
// not finish) has no instance.json — it never got that far. Its marker carries
|
|
@@ -6390,6 +6822,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6390
6822
|
if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
|
|
6391
6823
|
meta.launched = quarantine.cleanup.launched;
|
|
6392
6824
|
meta.tmux = quarantine.cleanup.tmux;
|
|
6825
|
+
meta.sessionTarget = quarantine.cleanup.sessionTarget;
|
|
6393
6826
|
}
|
|
6394
6827
|
// A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
|
|
6395
6828
|
// retired safely: hooks would be skipped and the directory removed, which is
|
|
@@ -6409,8 +6842,13 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6409
6842
|
const workPath = join(found.home, "work");
|
|
6410
6843
|
const isWorktree = meta.work === "worktree" ||
|
|
6411
6844
|
(existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
|
|
6412
|
-
|
|
6413
|
-
|
|
6845
|
+
// A live runtime cannot establish a stable final work inspection of itself,
|
|
6846
|
+
// so self-retire never inspects, runs hooks, or removes anything here. It
|
|
6847
|
+
// persists the intent and hands the whole retirement to a detached process
|
|
6848
|
+
// that runs it as an ordinary EXTERNAL retirement once the runtime is gone
|
|
6849
|
+
// (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
|
|
6850
|
+
if (self && (!o.keepDir || meta.sessionTarget)) {
|
|
6851
|
+
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
6414
6852
|
}
|
|
6415
6853
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
6416
6854
|
// the managed runtime; recovery copying never races a live managed Pi.
|
|
@@ -6425,11 +6863,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6425
6863
|
if (!runtimeAuthority) {
|
|
6426
6864
|
throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
|
|
6427
6865
|
}
|
|
6428
|
-
const
|
|
6429
|
-
meta.tmux?.
|
|
6430
|
-
meta.tmux?.
|
|
6431
|
-
|
|
6432
|
-
|
|
6866
|
+
const endpointAgrees = runtimeAuthority.sessionTarget
|
|
6867
|
+
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
|
|
6868
|
+
: !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
|
|
6869
|
+
&& meta.tmux?.window === runtimeAuthority.tmux?.window
|
|
6870
|
+
&& resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
|
|
6871
|
+
const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
|
|
6433
6872
|
if (!metaAgrees) {
|
|
6434
6873
|
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
|
|
6435
6874
|
}
|
|
@@ -6437,7 +6876,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6437
6876
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
6438
6877
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
6439
6878
|
// window absence established before recovery copying begins.
|
|
6440
|
-
if (!self && runtimeAuthority?.launched) {
|
|
6879
|
+
if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
|
|
6880
|
+
try { stopHerdr(runtimeAuthority.sessionTarget); }
|
|
6881
|
+
catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
|
|
6882
|
+
}
|
|
6883
|
+
if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
|
|
6441
6884
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
6442
6885
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
6443
6886
|
const runtimeSocket = runtimeAuthority.tmux.socket;
|
|
@@ -6480,13 +6923,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6480
6923
|
// describes. A hook that reports nothing, or reports retired:true, is
|
|
6481
6924
|
// unaffected; only an explicit "I did not finish" changes the outcome.
|
|
6482
6925
|
let ordinaryIncomplete = [];
|
|
6483
|
-
//
|
|
6484
|
-
//
|
|
6485
|
-
//
|
|
6486
|
-
//
|
|
6487
|
-
//
|
|
6488
|
-
//
|
|
6489
|
-
// owner to reconcile.
|
|
6926
|
+
// The IN-PROCESS self path (--self --keep-dir only, since aweb-abep) is
|
|
6927
|
+
// excluded: that caller is the instance and cannot hold the authority to
|
|
6928
|
+
// complete owner cleanup (reviewer-5c8b724). A plain --self never reaches
|
|
6929
|
+
// here as `self`: its deferred completion calls retireInstance as an
|
|
6930
|
+
// external operator, so a self-retiring reviewer or harvester whose hook
|
|
6931
|
+
// reports incomplete cleanup IS quarantined like any other instance.
|
|
6490
6932
|
if (!quarantine && !self) {
|
|
6491
6933
|
for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
|
|
6492
6934
|
// A hook may exit 0 and still report it did not finish. Only an explicit
|
|
@@ -6669,7 +7111,15 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6669
7111
|
// unreachable remote) would be unremovable through OATS forever — the same
|
|
6670
7112
|
// dead end the unusable-marker fixes closed, just reached from a valid one.
|
|
6671
7113
|
const forced = !!(stillIncomplete && o.force);
|
|
6672
|
-
if (!o.keepDir && (!stillIncomplete || forced))
|
|
7114
|
+
if (!o.keepDir && (!stillIncomplete || forced)) {
|
|
7115
|
+
rmSync(found.home, { recursive: true, force: true });
|
|
7116
|
+
// The owed retirement is paid: clear the pending marker, and the failed
|
|
7117
|
+
// outcome an earlier deferred attempt may have left beside the home; a
|
|
7118
|
+
// deferred completion writes its own outcome after this returns.
|
|
7119
|
+
rmSync(retirePendingMarkerPath(found.home), { force: true });
|
|
7120
|
+
rmSync(deferredRetireResultPath(found.home), { force: true });
|
|
7121
|
+
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
7122
|
+
}
|
|
6673
7123
|
|
|
6674
7124
|
|
|
6675
7125
|
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 };
|