@awebai/oats 0.22.0 → 0.22.1
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 +30 -47
- package/bin/oats.mjs +11 -6
- 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/oats.json +6 -6
- 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/soul.yaml +6 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +82 -51
- package/capabilities/oats-okf/oats.json +4 -1
- package/capabilities/oats-review/oats.json +1 -1
- package/docs/2026-09-03-architecture-proposal.md +642 -0
- package/docs/first-team-demo.md +87 -0
- package/docs/first-team.md +179 -0
- package/docs/integrations.md +83 -65
- package/docs/layers.md +356 -80
- package/docs/migration-from-oas.md +80 -116
- package/docs/release-notes/v0.22.1.md +106 -0
- package/lib/core.mjs +255 -363
- package/package.json +1 -1
- package/packages/record/README.md +76 -16
- package/packages/record/docs/turn-record-sot.md +1 -1
- package/packages/record/lib/store.mjs +207 -43
- 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/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +0 -0
package/lib/core.mjs
CHANGED
|
@@ -4395,7 +4395,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4395
4395
|
// OATS_INSTANCE_HOME is the runtime-neutral contract name for the
|
|
4396
4396
|
// instance home (absolute). OATS_HOME predates it and stays as a
|
|
4397
4397
|
// compatibility alias: shipped capability hooks read it
|
|
4398
|
-
// (
|
|
4398
|
+
// (the official oats.aweb and oats.okf packages) and are versioned
|
|
4399
4399
|
// independently of this kernel. Neither is OATS_HOME_DIR, which is
|
|
4400
4400
|
// the package STORE root — do not conflate them.
|
|
4401
4401
|
OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
|
|
@@ -4665,7 +4665,7 @@ export function appendLogEntry(file, entry, title = "Log") {
|
|
|
4665
4665
|
writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
|
|
4666
4666
|
}
|
|
4667
4667
|
|
|
4668
|
-
// (soul knowledge scaffolding belongs to
|
|
4668
|
+
// (soul knowledge scaffolding belongs to the oats.okf package — soul-scaffold hook)
|
|
4669
4669
|
|
|
4670
4670
|
// ---------- soul scaffolding ----------
|
|
4671
4671
|
function fileSnapshot(dir) {
|
|
@@ -5333,6 +5333,13 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5333
5333
|
// reconciliation, so this spawn-time check is the authoritative one.
|
|
5334
5334
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
|
|
5335
5335
|
|
|
5336
|
+
// Prerequisites must fail before creating a home, worktree, or identity.
|
|
5337
|
+
const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
|
|
5338
|
+
const bin = which(runtime === "claude" ? claudeBin : "pi");
|
|
5339
|
+
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("tmux")) throw new Error("tmux not installed (brew install tmux)");
|
|
5341
|
+
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5342
|
+
|
|
5336
5343
|
mkdirSync(home, { recursive: true });
|
|
5337
5344
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
5338
5345
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -5508,41 +5515,54 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5508
5515
|
// Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
|
|
5509
5516
|
// memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
|
|
5510
5517
|
// messaging integration mints the comms identity. Kernel stays memory-agnostic.
|
|
5511
|
-
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
5512
5518
|
const hookRes = runLifecycleHooks("spawn", {
|
|
5513
5519
|
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
5514
5520
|
workspaceDir: workspaceOf(root), resolved: resolvedCfg,
|
|
5515
5521
|
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
|
|
5516
5522
|
});
|
|
5517
5523
|
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
5524
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
5526
|
-
|
|
5527
|
-
|
|
5525
|
+
let windowMayExist = false;
|
|
5526
|
+
let spawnTmux;
|
|
5527
|
+
const ancillaryCleanup = [];
|
|
5528
|
+
// One compensation owner, from the first hook result through launch and
|
|
5529
|
+
// the final lineage write. Preserve the original failure and retain any
|
|
5530
|
+
// credentials/metadata whose cleanup could not be confirmed.
|
|
5531
|
+
const compensateSpawn = () => {
|
|
5532
|
+
const failed = requiredFailures.length
|
|
5533
|
+
? requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) }))
|
|
5534
|
+
: [{ capability: "oats.kernel", event: "spawn" }];
|
|
5535
|
+
const incomplete = [...ancillaryCleanup];
|
|
5528
5536
|
const probe = (argv) => {
|
|
5529
5537
|
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() }; }
|
|
5538
|
+
// An absent ref exits 1 with empty stderr; preserve that distinction.
|
|
5539
|
+
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5537
5540
|
};
|
|
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).
|
|
5541
|
+
// Retain cleanup owners so a retry must actually discharge their debt.
|
|
5544
5542
|
const outstandingHooks = new Set();
|
|
5545
5543
|
const outstandingGit = new Set();
|
|
5544
|
+
// A failed new-window command may still have created its window. Verify
|
|
5545
|
+
// quiescence before removing credentials or work that runtime may be using.
|
|
5546
|
+
if (windowMayExist) {
|
|
5547
|
+
shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
|
|
5548
|
+
const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
5549
|
+
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
5550
|
+
if (unresolved) {
|
|
5551
|
+
incomplete.push(!winProbe.ok
|
|
5552
|
+
? `tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`
|
|
5553
|
+
: `tmux window ${session}:${instance} still running`);
|
|
5554
|
+
for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
|
|
5555
|
+
if (work === "worktree") {
|
|
5556
|
+
outstandingGit.add("worktree");
|
|
5557
|
+
if (branch) outstandingGit.add("branch");
|
|
5558
|
+
}
|
|
5559
|
+
return quarantineInstanceHome({
|
|
5560
|
+
home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
|
|
5561
|
+
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
5562
|
+
launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5565
|
+
}
|
|
5546
5566
|
let compensationMeta = {};
|
|
5547
5567
|
try {
|
|
5548
5568
|
const comp = runLifecycleHooks("retire", {
|
|
@@ -5597,25 +5617,13 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5597
5617
|
incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
|
|
5598
5618
|
outstandingHooks.add(cap.id);
|
|
5599
5619
|
}
|
|
5600
|
-
const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
|
|
5601
5620
|
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
|
-
}
|
|
5621
|
+
if (outstandingHooks.size || outstandingGit.size) {
|
|
5622
|
+
// Preserve credentials and the original hook receipt until cleanup
|
|
5623
|
+
// succeeds. The runtime is stopped; Git cleanup was independently safe.
|
|
5616
5624
|
note = quarantineInstanceHome({
|
|
5617
5625
|
home, instance, agent, incomplete,
|
|
5618
|
-
failed
|
|
5626
|
+
failed,
|
|
5619
5627
|
outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
|
|
5620
5628
|
hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
|
|
5621
5629
|
recordRetirementBaseline: true,
|
|
@@ -5625,18 +5633,24 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5625
5633
|
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
5626
5634
|
note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
|
|
5627
5635
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5636
|
+
return note;
|
|
5637
|
+
};
|
|
5638
|
+
|
|
5639
|
+
try {
|
|
5640
|
+
if (requiredFailures.length) {
|
|
5641
|
+
const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
|
|
5642
|
+
const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
|
|
5643
|
+
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`);
|
|
5644
|
+
}
|
|
5645
|
+
const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
|
|
5646
|
+
const workDesc = work === "worktree"
|
|
5647
|
+
? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
|
|
5648
|
+
: work === "attached"
|
|
5649
|
+
? `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`
|
|
5650
|
+
: work === "workspace"
|
|
5651
|
+
? `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)`
|
|
5652
|
+
: `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`;
|
|
5653
|
+
writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
|
|
5640
5654
|
|
|
5641
5655
|
You are instance "${instance}" of agent "${agent.name}".
|
|
5642
5656
|
- Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
|
|
@@ -5644,315 +5658,198 @@ You are instance "${instance}" of agent "${agent.name}".
|
|
|
5644
5658
|
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
|
|
5645
5659
|
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
|
|
5646
5660
|
|
|
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
|
-
canonicalSkillTree: join(home, ".agents", "skills"),
|
|
5767
|
-
skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
|
|
5661
|
+
// Launch command. Spawn IS session start: this command is persisted in
|
|
5662
|
+
// instance.json and executed in the instance's tmux window. Capabilities may
|
|
5663
|
+
// contribute runtime-specific arguments via their spawn hook's `launch` map
|
|
5664
|
+
// (e.g. aweb's Claude Code channel plugin flags).
|
|
5665
|
+
const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
|
|
5666
|
+
let cmdline;
|
|
5667
|
+
if (runtime === "claude") {
|
|
5668
|
+
// .claude/skills already links the OATS-composed instance skill set.
|
|
5669
|
+
// "--" terminates option parsing BEFORE the prompt: capability launch
|
|
5670
|
+
// hooks can contribute greedy/variadic flags (e.g. aweb's
|
|
5671
|
+
// --dangerously-load-development-channels), and without the separator
|
|
5672
|
+
// the TASK.md text is swallowed as that flag's next value — claude
|
|
5673
|
+
// errors out ("entries must be tagged: <task text>") and the window
|
|
5674
|
+
// drops to the fallback shell, which reads as a silently stuck spawn.
|
|
5675
|
+
cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
|
|
5676
|
+
} else {
|
|
5677
|
+
// STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
|
|
5678
|
+
// or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
|
|
5679
|
+
// NOT "nothing else can contribute": extensions stay ambient by founder
|
|
5680
|
+
// ruling (see below), and an extension's resources_discover hook can add
|
|
5681
|
+
// skill paths that survive --no-skills. The OATS-managed root is exact; the
|
|
5682
|
+
// extension surface is the operator's, and stating otherwise here would
|
|
5683
|
+
// contradict the paragraph twelve lines down (reviewer-aggregate2).
|
|
5684
|
+
//
|
|
5685
|
+
// --no-skills ends discovery; --skill stays additive.
|
|
5686
|
+
// --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
|
|
5687
|
+
// It also stops the instance's OWN composed AGENTS.md
|
|
5688
|
+
// loading, so that is delivered explicitly via
|
|
5689
|
+
// --append-system-prompt. The work tree's AGENTS.md
|
|
5690
|
+
// stays READABLE by the read tool: readable, not
|
|
5691
|
+
// auto-injected, is the contract.
|
|
5692
|
+
// --no-prompt-templates same posture for ambient prompt templates.
|
|
5693
|
+
//
|
|
5694
|
+
// Built-in tools and pi's native interaction model are untouched — OATS
|
|
5695
|
+
// curates the curriculum, it does not cripple the runtime.
|
|
5696
|
+
//
|
|
5697
|
+
// EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
|
|
5698
|
+
// extensions (web search, output formatting) that every instance should
|
|
5699
|
+
// keep. So no --no-extensions, and no -e flags either — pi discovers the
|
|
5700
|
+
// installed extensions itself, and passing them explicitly as well would
|
|
5701
|
+
// load the same extension twice.
|
|
5702
|
+
//
|
|
5703
|
+
// The trade-off is deliberate and narrow: an extension's
|
|
5704
|
+
// `resources_discover` hook can contribute skill paths that survive
|
|
5705
|
+
// --no-skills. Today only the OATS bridge does that, and inside an instance
|
|
5706
|
+
// it contributes that instance's OWN .agents/skills, so the composed set is
|
|
5707
|
+
// unchanged. A third-party extension that contributes skills WOULD add them,
|
|
5708
|
+
// which is the accepted residue of keeping shared extensions working.
|
|
5709
|
+
// Capability-required runtime packages are still verified and recorded
|
|
5710
|
+
// (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
|
|
5711
|
+
// still holds — it is loaded by pi's own discovery rather than by flag.
|
|
5712
|
+
// pi has no `--` end-of-options marker (it rejects `--` in every position),
|
|
5713
|
+
// so the task positional goes AHEAD of capability-contributed options:
|
|
5714
|
+
// nothing preceding it is waiting for a value, so a trailing variadic
|
|
5715
|
+
// contributed flag cannot consume the task.
|
|
5716
|
+
cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
|
|
5717
|
+
+ ` --no-context-files --no-prompt-templates`
|
|
5718
|
+
+ ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
|
|
5719
|
+
+ ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
|
|
5720
|
+
}
|
|
5721
|
+
// OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
|
|
5722
|
+
// the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
|
|
5723
|
+
// are pi-branded predecessors kept as compatibility aliases: the separately
|
|
5724
|
+
// published @awebai/oats-pi extension and bin/oats.mjs still read them, and
|
|
5725
|
+
// an older installed extension must keep working against a newer kernel.
|
|
5726
|
+
const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
|
|
5727
|
+
cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
|
|
5728
|
+
|
|
5729
|
+
const meta = {
|
|
5730
|
+
agent: agent.name, kind: agent.kind || "persistent", instance, home,
|
|
5731
|
+
repo: repoAbs, work, branch, runtime, model: model || undefined,
|
|
5732
|
+
team: resolvedCfg.team || undefined,
|
|
5733
|
+
parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
|
|
5734
|
+
siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
|
|
5735
|
+
relation: relation || undefined,
|
|
5736
|
+
relativeTo: relation ? relativeTo : undefined,
|
|
5737
|
+
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
5738
|
+
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
5739
|
+
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
5740
|
+
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
5741
|
+
id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
|
|
5742
|
+
settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
|
|
5743
|
+
hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
|
|
5744
|
+
...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
|
|
5745
|
+
})),
|
|
5746
|
+
skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
|
|
5747
|
+
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
5748
|
+
// The auditable record of the curriculum: what the resolved composition
|
|
5749
|
+
// PROMISED, and what actually landed. Both are asserted equal before launch;
|
|
5750
|
+
// keeping both makes an instance's surface reviewable after the fact without
|
|
5751
|
+
// re-resolving config that may since have changed.
|
|
5752
|
+
composition: {
|
|
5753
|
+
expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
|
|
5754
|
+
materialized: {
|
|
5755
|
+
skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
|
|
5756
|
+
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
5757
|
+
// `filtered` records that the operator's settings entry narrows this
|
|
5758
|
+
// package's resources. A non-empty filter is a deliberate choice whose
|
|
5759
|
+
// glob semantics belong to the runtime, so it is auditable here rather
|
|
5760
|
+
// than second-guessed at spawn.
|
|
5761
|
+
runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
|
|
5762
|
+
// What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
|
|
5763
|
+
// so the deviation from strict composition is auditable instead of
|
|
5764
|
+
// implied — the honest contract, not an aspiration.
|
|
5765
|
+
runtimePosture: runtime === "claude"
|
|
5766
|
+
? {
|
|
5767
|
+
oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
|
|
5768
|
+
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
|
+
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
|
+
}
|
|
5771
|
+
: {
|
|
5772
|
+
oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
|
|
5773
|
+
curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
|
|
5774
|
+
ambient: ["globally configured pi extensions, and any resources they contribute"],
|
|
5775
|
+
why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
|
|
5776
|
+
},
|
|
5777
|
+
canonicalSkillTree: join(home, ".agents", "skills"),
|
|
5778
|
+
skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
|
|
5779
|
+
},
|
|
5768
5780
|
},
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
|
|
5788
|
-
shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
|
|
5789
|
-
}
|
|
5790
|
-
if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
|
|
5791
|
-
meta.tmux.socket = tmuxSocket(session);
|
|
5792
|
-
meta.launched = true;
|
|
5793
|
-
// Commit the final child metadata and its independent byte authority before
|
|
5794
|
-
// the managed runtime can write. No child-home transition follows launch.
|
|
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; }
|
|
5781
|
+
capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
|
|
5782
|
+
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
5783
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
5784
|
+
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
5785
|
+
executable: cap.executable,
|
|
5786
|
+
})),
|
|
5787
|
+
tmux: { session, window: instance },
|
|
5788
|
+
command: cmdline, createdAt: new Date().toISOString(),
|
|
5789
|
+
};
|
|
5790
|
+
const spawnWarnings = warnings;
|
|
5791
|
+
|
|
5792
|
+
spawnTmux = meta.tmux;
|
|
5793
|
+
if (launch) {
|
|
5794
|
+
if (!tmuxAlive(session)) {
|
|
5795
|
+
const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
|
|
5796
|
+
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
5797
|
+
shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
|
|
5798
|
+
shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
|
|
5861
5799
|
}
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5800
|
+
if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
|
|
5801
|
+
meta.tmux.socket = tmuxSocket(session);
|
|
5802
|
+
meta.launched = true;
|
|
5803
|
+
// Commit the final child metadata and its independent byte authority before
|
|
5804
|
+
// the managed runtime can write. No child-home transition follows launch.
|
|
5805
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5806
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
5807
|
+
// Wrap the command so the window drops into an interactive shell when the
|
|
5808
|
+
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
5809
|
+
const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
|
|
5810
|
+
windowMayExist = true;
|
|
5811
|
+
sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
|
|
5812
|
+
} else {
|
|
5813
|
+
meta.launched = false;
|
|
5814
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
5815
|
+
writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
|
|
5816
|
+
}
|
|
5817
|
+
|
|
5818
|
+
// parent relation: re-point the ANCHOR's recorded lineage so its parent is
|
|
5819
|
+
// the new instance (e.g. a maintainer reviewing the spawner sits above it).
|
|
5820
|
+
// Committed LAST — after every other fallible step incl. launch — so a
|
|
5821
|
+
// failed spawn (missing tmux, window collision, new-window error) never
|
|
5822
|
+
// leaves the anchor's graph pointing at a zombie. --no-launch reaches here
|
|
5823
|
+
// too: the scaffold itself succeeded, which is that path's definition of
|
|
5824
|
+
// success. The write ITSELF is fallible (anchor retired concurrently,
|
|
5825
|
+
// unwritable file): on failure the spawn is COMPENSATED — kill the launched
|
|
5826
|
+
// window and remove the scaffold — so the operation stays all-or-nothing:
|
|
5827
|
+
// either agent live + lineage recorded, or neither.
|
|
5828
|
+
if (relation === "parent" && anchorMeta && anchorMetaPath) {
|
|
5829
|
+
// Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
|
|
5830
|
+
// failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
|
|
5831
|
+
// Write a same-directory temp file and rename it over the anchor — rename
|
|
5832
|
+
// is atomic on POSIX, so the anchor is always either old or new, never
|
|
5833
|
+
// truncated.
|
|
5834
|
+
const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
|
|
5867
5835
|
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)";
|
|
5836
|
+
anchorMeta.parentInstance = instance;
|
|
5837
|
+
delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
|
|
5838
|
+
writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
|
|
5839
|
+
renameSync(tmpPath, anchorMetaPath);
|
|
5840
|
+
} catch (e) {
|
|
5841
|
+
try { rmSync(tmpPath, { force: true }); }
|
|
5842
|
+
catch (cleanupError) { ancillaryCleanup.push(`temp file ${tmpPath}: ${cleanupError.message}`); }
|
|
5843
|
+
throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message})`);
|
|
5950
5844
|
}
|
|
5951
|
-
throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message}) — ${rollbackNote}`);
|
|
5952
5845
|
}
|
|
5953
|
-
}
|
|
5954
5846
|
|
|
5955
|
-
|
|
5847
|
+
return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
5848
|
+
} catch (error) {
|
|
5849
|
+
const note = compensateSpawn();
|
|
5850
|
+
error.message += note;
|
|
5851
|
+
throw error;
|
|
5852
|
+
}
|
|
5956
5853
|
}
|
|
5957
5854
|
|
|
5958
5855
|
export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
@@ -6068,13 +5965,8 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
|
|
|
6068
5965
|
/** The rollback-owned Git steps a quarantine can still owe. */
|
|
6069
5966
|
export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
|
|
6070
5967
|
|
|
6071
|
-
/** Retain
|
|
6072
|
-
* finish the
|
|
6073
|
-
* rollback paths — a failed required hook, and a failure after the instance was
|
|
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. */
|
|
5968
|
+
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
5969
|
+
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
6078
5970
|
function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
|
|
6079
5971
|
try {
|
|
6080
5972
|
writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
|