@awebai/oats 0.24.7 → 0.24.9
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/bin/oats.mjs +233 -20
- package/capabilities/oats-okf/bin/oats-okf.mjs +3 -3
- package/capabilities/oats-okf/lib/binding-wire.mjs +35 -8
- package/capabilities/oats-okf/lib/config.mjs +1 -0
- package/capabilities/oats-okf/lib/sources.mjs +27 -2
- package/capabilities/oats-okf/lib/worker.mjs +5 -2
- package/capabilities/oats-okf/oats.json +29 -6
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-22-desktop-parity-seams.md +4 -4
- package/docs/desktop-cli-api.md +392 -3
- package/docs/release-notes/v0.24.7.md +2 -2
- package/docs/release-notes/v0.24.8.md +107 -0
- package/docs/release-notes/v0.24.9.md +67 -0
- package/docs/workspace-adoption.md +4 -1
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +263 -21
- package/lib/instance-events.mjs +76 -0
- package/lib/instance-git.mjs +21 -0
- package/lib/instance-lifecycle.mjs +181 -0
- package/lib/portable-soul.mjs +5 -1
- package/lib/process-group.mjs +12 -0
- package/lib/readiness.mjs +225 -0
- package/lib/schedule.mjs +22 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
27
27
|
* (attached as soul default is for service agents — spawn must supply workDir)
|
|
28
28
|
*/
|
|
29
|
-
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
29
|
+
import { execFileSync, execSync, spawn as spawnProcess, spawnSync } from "node:child_process";
|
|
30
30
|
import {
|
|
31
31
|
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
@@ -40,6 +40,8 @@ import { PI_SDK_HOST, isPiSdkHost, validatePiHostRecipe, piHostArgv, resolvePiSd
|
|
|
40
40
|
import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapturedPiRoot, prepareCapturedPiStart } from "./captured-pi-custody.mjs";
|
|
41
41
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
|
+
import { appendEvent } from "./instance-events.mjs";
|
|
44
|
+
import { killGroup } from "./process-group.mjs";
|
|
43
45
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
44
46
|
|
|
45
47
|
import { oatsError } from "./errors.mjs";
|
|
@@ -128,6 +130,30 @@ function legacyOperationalSkills(soulDir) {
|
|
|
128
130
|
// ---------- shell helpers ----------
|
|
129
131
|
function sh(cmdline) { return execSync(cmdline, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
130
132
|
function shTry(cmdline) { try { return sh(cmdline); } catch { return undefined; } }
|
|
133
|
+
/** A native PROBE (a runtime binary asked about its catalogue or packages) under
|
|
134
|
+
* a preview: bounded by what is left of the shared preflight budget, run in its
|
|
135
|
+
* own process group and group-killed on timeout. Cheap lookups (`command -v`)
|
|
136
|
+
* are not probes and never draw from the budget. Outside a preview: shTry. */
|
|
137
|
+
function probeTry(cmdline) {
|
|
138
|
+
if (!previewPreflightBudget) return shTry(cmdline);
|
|
139
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
140
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; return undefined; }
|
|
141
|
+
const r = spawnSync("/bin/sh", ["-c", cmdline], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false" } });
|
|
142
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; return undefined; }
|
|
143
|
+
return r.status === 0 ? String(r.stdout).trim() : undefined;
|
|
144
|
+
}
|
|
145
|
+
let previewPreflightBudget = null;
|
|
146
|
+
/** execFileSync for a native probe: under a preview budget, the timeout is what
|
|
147
|
+
* is left of it and the child is group-killed; otherwise the caller's timeout. */
|
|
148
|
+
function probeExecFile(file, args, options = {}) {
|
|
149
|
+
if (!previewPreflightBudget) return execFileSync(file, args, options);
|
|
150
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
151
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; throw Object.assign(new Error("preflight budget exhausted"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
152
|
+
const r = spawnSync(file, args, { ...options, timeout: Math.min(left, options.timeout ?? left), killSignal: "SIGKILL", detached: true });
|
|
153
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; throw r.error || Object.assign(new Error("probe killed"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
154
|
+
if (r.status !== 0) throw Object.assign(new Error(`probe exited ${r.status}`), { status: r.status, stdout: r.stdout });
|
|
155
|
+
return r.stdout;
|
|
156
|
+
}
|
|
131
157
|
function shIn(cwd, cmdline, timeout = 45000) {
|
|
132
158
|
return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
|
|
133
159
|
}
|
|
@@ -4326,7 +4352,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4326
4352
|
try {
|
|
4327
4353
|
// The SELECTED executable answers (a wrapper or another binary), with
|
|
4328
4354
|
// pi's own controlled list subcommand only: never a launch argument.
|
|
4329
|
-
const out =
|
|
4355
|
+
const out = probeExecFile(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
|
|
4330
4356
|
const rows = [];
|
|
4331
4357
|
// pi dims the path with chalk; strip any escapes before matching.
|
|
4332
4358
|
const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
|
|
@@ -4417,7 +4443,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4417
4443
|
list: (env = process.env, opts = {}) => {
|
|
4418
4444
|
let out;
|
|
4419
4445
|
try {
|
|
4420
|
-
out =
|
|
4446
|
+
out = probeExecFile(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
|
|
4421
4447
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60000, env });
|
|
4422
4448
|
} catch { return []; }
|
|
4423
4449
|
let rows;
|
|
@@ -5421,7 +5447,7 @@ export function resolveModelPreference(model, runtime = "pi") {
|
|
|
5421
5447
|
const [provider, ...rest] = bare.split("/");
|
|
5422
5448
|
const id = rest.join("/");
|
|
5423
5449
|
if (!id) return pref; // bare pattern (no provider) — let pi resolve it
|
|
5424
|
-
const out =
|
|
5450
|
+
const out = probeTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
|
|
5425
5451
|
const found = out.split("\n").some((line) => {
|
|
5426
5452
|
const cols = line.trim().split(/\s+/);
|
|
5427
5453
|
return cols[0] === provider && cols[1] === id;
|
|
@@ -5565,6 +5591,8 @@ export function missingLaunchEnvRefs(configEnv, env = process.env) {
|
|
|
5565
5591
|
* configuration behind (its executable and args are not carried), and a
|
|
5566
5592
|
* model never crosses runtimes: explicit or configured model, else the
|
|
5567
5593
|
* frozen model on the same runtime, else the runtime's native default. */
|
|
5594
|
+
/** Sentinel a caller passes as `model` to mean "the runtime's own default, not the configured/soul preference". */
|
|
5595
|
+
export const NATIVE_DEFAULT_MODEL = "@native-default";
|
|
5568
5596
|
export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
|
|
5569
5597
|
const bad = (code, msg) => { throw oatsError(code, msg); };
|
|
5570
5598
|
let wanted = selection.launchConfig;
|
|
@@ -5588,8 +5616,13 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
5588
5616
|
const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
|
|
5589
5617
|
if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
|
|
5590
5618
|
let model, modelSource;
|
|
5591
|
-
|
|
5592
|
-
|
|
5619
|
+
// K6: an EXPLICIT "use the runtime's native default" is distinct from an
|
|
5620
|
+
// omitted model (which inherits the configuration's or soul's preference).
|
|
5621
|
+
const nativeDefault = selection.model === NATIVE_DEFAULT_MODEL || (selection.model && typeof selection.model === "object" && selection.model.kind === "native-default");
|
|
5622
|
+
const explicit = !nativeDefault && selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
|
|
5623
|
+
if (nativeDefault) {
|
|
5624
|
+
model = ""; modelSource = "native default (explicit)";
|
|
5625
|
+
} else if (explicit) {
|
|
5593
5626
|
model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
|
|
5594
5627
|
if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
|
|
5595
5628
|
} else if (config?.model) {
|
|
@@ -5857,7 +5890,14 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5857
5890
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
5858
5891
|
// Launch selection: a named configuration (explicit, or the soul's
|
|
5859
5892
|
// launch-config default), or none; the runtime and model follow from it.
|
|
5860
|
-
|
|
5893
|
+
// K6b: a preview's native probes (model catalogue, runtime packages) share
|
|
5894
|
+
// ONE budget from here to the return; each probe is group-killed on timeout.
|
|
5895
|
+
const preflightStarted = Date.now(), preflightBudgetMs = o.preview === true ? (Number(process.env.OATS_PREVIEW_PREFLIGHT_BUDGET_MS) || 20000) : undefined;
|
|
5896
|
+
let preflight = { status: "complete", budgetMs: preflightBudgetMs ?? null };
|
|
5897
|
+
if (o.preview === true) previewPreflightBudget = { deadline: preflightStarted + preflightBudgetMs };
|
|
5898
|
+
let launchSelection;
|
|
5899
|
+
try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } }); }
|
|
5900
|
+
catch (e) { previewPreflightBudget = null; throw e; }
|
|
5861
5901
|
const launchConfig = launchSelection.config;
|
|
5862
5902
|
const runtime = launchSelection.runtime;
|
|
5863
5903
|
const model = launchSelection.model;
|
|
@@ -6079,6 +6119,34 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6079
6119
|
if (anchorMeta?.siblingInstance) siblingInstance = anchorMeta.siblingInstance;
|
|
6080
6120
|
}
|
|
6081
6121
|
if (!relation && attachedOwner && attachedOwner !== instance) parentInstance = attachedOwner;
|
|
6122
|
+
// K5 §4 — child-spawn permission is ENFORCED here, by the spawn route. The
|
|
6123
|
+
// parent's recorded policy (instance.json policy.childSpawns) says whether
|
|
6124
|
+
// it may have children; off refuses, attributed to that policy and its
|
|
6125
|
+
// origin. Absent policy = allowed (today's behaviour), reported as such.
|
|
6126
|
+
// A lifecycle-authority claim, not an OS sandbox.
|
|
6127
|
+
const childPolicyOf = (metaOf) => metaOf?.policy?.childSpawns && typeof metaOf.policy.childSpawns === "object"
|
|
6128
|
+
? metaOf.policy.childSpawns : { allowed: true, origin: { kind: "default", detail: "no recorded policy: children allowed" } };
|
|
6129
|
+
if (parentInstance && parentInstance !== instance) {
|
|
6130
|
+
const parentHome = parentInstance === relativeTo && anchorHome ? anchorHome
|
|
6131
|
+
: (findInstanceHome(root, parentInstance) || findTeamInstance(root, parentInstance))?.home;
|
|
6132
|
+
const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
|
|
6133
|
+
const policy = childPolicyOf(parentMeta);
|
|
6134
|
+
if (policy.allowed === false) {
|
|
6135
|
+
// A preview touches nothing — not even the parent's event log; the
|
|
6136
|
+
// typed refusal IS the preview's answer.
|
|
6137
|
+
if (parentHome && o.preview !== true) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
|
|
6138
|
+
throw Object.assign(oatsError("E_CHILD_SPAWNS_DISABLED", `${parentInstance} does not allow child spawns (policy origin: ${policy.origin?.kind ?? "recorded"}${policy.origin?.detail ? ` — ${policy.origin.detail}` : ""}); nothing was spawned. Spawn without a parent relation, or respawn the parent with --allow-child-spawns.`),
|
|
6139
|
+
{ parent: parentInstance, policy });
|
|
6140
|
+
}
|
|
6141
|
+
}
|
|
6142
|
+
// This instance's own policy: soul declaration (children.spawn), overridden
|
|
6143
|
+
// per spawn; recorded so the route above and the readiness view can read it.
|
|
6144
|
+
const declaredChildren = agent.children && typeof agent.children === "object" ? agent.children : (typeof agent.children === "string" ? (() => { try { return JSON.parse(agent.children); } catch { return undefined; } })() : undefined);
|
|
6145
|
+
const ownChildPolicy = o.allowChildSpawns !== undefined
|
|
6146
|
+
? { allowed: o.allowChildSpawns === true, origin: { kind: "spawn-option", detail: o.allowChildSpawns ? "--allow-child-spawns" : "--no-child-spawns" } }
|
|
6147
|
+
: declaredChildren && typeof declaredChildren.spawn === "boolean"
|
|
6148
|
+
? { allowed: declaredChildren.spawn, origin: { kind: "soul", detail: `children.spawn: ${declaredChildren.spawn} in soul.yaml` } }
|
|
6149
|
+
: { allowed: true, origin: { kind: "default", detail: "no declaration: children allowed" } };
|
|
6082
6150
|
|
|
6083
6151
|
// Inherited edges must round-trip too. Sibling and parent relations copy
|
|
6084
6152
|
// names from the ANCHOR's instance.json (anchorMeta.parentInstance /
|
|
@@ -6186,11 +6254,55 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6186
6254
|
// spawn hooks run, which happens after the home exists.
|
|
6187
6255
|
if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
6188
6256
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
6189
|
-
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
6190
|
-
|
|
6257
|
+
if (launch && o.preview !== true && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
6258
|
+
// Preview never starts a backend daemon: it reports reachability as observed
|
|
6259
|
+
// (binary present?) and leaves the socket alone.
|
|
6260
|
+
const herdrBase = launch && backend === "herdr" && o.preview !== true ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
6261
|
+
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
6191
6262
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
6192
6263
|
|
|
6193
6264
|
if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
|
|
6265
|
+
// K6: everything a spawn decides is decided by here — and nothing has been
|
|
6266
|
+
// touched. Branch/base for a worktree are named now (not after mkdir) so the
|
|
6267
|
+
// preview and the apply agree on them; `o.baseRef` selects the start point.
|
|
6268
|
+
let plannedBranch = null, plannedBase = null;
|
|
6269
|
+
if (work === "worktree") {
|
|
6270
|
+
plannedBranch = o.branch || `agents/${instance}`;
|
|
6271
|
+
// Validity is Git's own rule (check-ref-format), not a stricter charset:
|
|
6272
|
+
// every later git call takes the name as an argv element, never through a
|
|
6273
|
+
// shell, so a valid-but-hostile name is safe and stays exercisable.
|
|
6274
|
+
try { execFileSync("git", ["check-ref-format", "--branch", plannedBranch], { stdio: ["ignore", "pipe", "pipe"] }); }
|
|
6275
|
+
catch { throw oatsError("E_BAD_ARGS", `branch ${JSON.stringify(plannedBranch)} is not a valid branch name`); }
|
|
6276
|
+
if (shInTry(repoAbs, `git rev-parse --verify --quiet ${shq("refs/heads/" + plannedBranch)}`) !== undefined) throw oatsError("E_BRANCH_EXISTS", `branch ${plannedBranch} already exists in ${repoAbs}; choose another name or reuse it deliberately`);
|
|
6277
|
+
const baseRef = o.baseRef || "HEAD";
|
|
6278
|
+
const baseOid = shInTry(repoAbs, `git rev-parse --verify --quiet ${shq(baseRef + "^{commit}")}`);
|
|
6279
|
+
if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
|
|
6280
|
+
plannedBase = { ref: baseRef, oid: baseOid };
|
|
6281
|
+
}
|
|
6282
|
+
if (o.preview === true) {
|
|
6283
|
+
const decision = { instance, home, branch: plannedBranch, base: plannedBase };
|
|
6284
|
+
decision.revision = createHash("sha256").update(canonicalJson(decision)).digest("hex").slice(0, 24);
|
|
6285
|
+
return {
|
|
6286
|
+
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6287
|
+
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6288
|
+
decision, preflight,
|
|
6289
|
+
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
6290
|
+
runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
6291
|
+
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
6292
|
+
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
6293
|
+
policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: resolvedCfg.capabilities.map((c) => c.id),
|
|
6294
|
+
skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
|
|
6295
|
+
};
|
|
6296
|
+
}
|
|
6297
|
+
if (o.expectDecision !== undefined) {
|
|
6298
|
+
// A confirmed preview binds THIS apply: same name, home, branch and base
|
|
6299
|
+
// oid, recomputed here under the same placement path. Any drift is a typed
|
|
6300
|
+
// refusal carrying the fresh decision — nothing is created, nothing is
|
|
6301
|
+
// auto-suffixed or silently re-based.
|
|
6302
|
+
const fresh = { instance, home, branch: plannedBranch, base: plannedBase };
|
|
6303
|
+
fresh.revision = createHash("sha256").update(canonicalJson(fresh)).digest("hex").slice(0, 24);
|
|
6304
|
+
if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
|
|
6305
|
+
}
|
|
6194
6306
|
mkdirSync(home, { recursive: true });
|
|
6195
6307
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6196
6308
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -6291,11 +6403,11 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6291
6403
|
let branch;
|
|
6292
6404
|
let worktreeCanonical; // captured immediately after add, before setup/hooks can mutate/remove it
|
|
6293
6405
|
if (work === "worktree") {
|
|
6294
|
-
branch =
|
|
6406
|
+
branch = plannedBranch;
|
|
6295
6407
|
const wt = join(home, "work");
|
|
6296
6408
|
let added = false;
|
|
6297
6409
|
try {
|
|
6298
|
-
execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch],
|
|
6410
|
+
execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch, plannedBase.oid],
|
|
6299
6411
|
{ stdio: ["ignore", "pipe", "pipe"] });
|
|
6300
6412
|
added = true;
|
|
6301
6413
|
// Git registers a canonical path. Retain it now: compensation hooks can
|
|
@@ -6610,6 +6722,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6610
6722
|
relation: relation || undefined,
|
|
6611
6723
|
relativeTo: relation ? relativeTo : undefined,
|
|
6612
6724
|
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
6725
|
+
policy: { childSpawns: ownChildPolicy },
|
|
6613
6726
|
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
6614
6727
|
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
6615
6728
|
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
@@ -6736,6 +6849,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6736
6849
|
}
|
|
6737
6850
|
}
|
|
6738
6851
|
|
|
6852
|
+
appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch } });
|
|
6853
|
+
if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
|
|
6739
6854
|
return { ...meta, launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), 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 };
|
|
6740
6855
|
} catch (error) {
|
|
6741
6856
|
const note = compensateSpawn();
|
|
@@ -7087,6 +7202,12 @@ function retirementBaselinePath(home) {
|
|
|
7087
7202
|
* loudly, and streaming is deliberately not attempted in this change. */
|
|
7088
7203
|
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
7089
7204
|
|
|
7205
|
+
/** Kernel-owned receipts the kernel itself appends to a home after the spawn
|
|
7206
|
+
* baseline (events log, stop/restart receipts). They are evidence about the
|
|
7207
|
+
* instance, not the instance's work, so a retirement fingerprint ignores them —
|
|
7208
|
+
* otherwise every stop or event write would read as "changed home bytes". */
|
|
7209
|
+
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
7210
|
+
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
7090
7211
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
7091
7212
|
const hash = createHash("sha256");
|
|
7092
7213
|
const rootStat = lstatSync(root);
|
|
@@ -7099,7 +7220,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
7099
7220
|
}
|
|
7100
7221
|
const walk = (dir, rel = "") => {
|
|
7101
7222
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7102
|
-
if ((!rel && excludeRoot.has(e.name)) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7223
|
+
if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7103
7224
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
7104
7225
|
const path = join(dir, e.name);
|
|
7105
7226
|
const st = lstatSync(path);
|
|
@@ -7275,6 +7396,73 @@ export function inspectInstanceSession(home) {
|
|
|
7275
7396
|
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
7276
7397
|
}
|
|
7277
7398
|
|
|
7399
|
+
/** K3: quiesce one instance's session and RETAIN everything else — home,
|
|
7400
|
+
* worktree, transcript, launch configuration — so `restart` can bring it back.
|
|
7401
|
+
* Exactly restart's stop half under the same independent endpoint authority
|
|
7402
|
+
* (`instanceSessionTarget`), bounded and never escalated: a harness still
|
|
7403
|
+
* there after the grace is reported still running, nothing is killed harder.
|
|
7404
|
+
* A no-launch or already-idle instance is a no-op that says so. */
|
|
7405
|
+
function tmuxServerLost(e) { return /no server running on |(?:error connecting to|failed to connect to) .*(?:No such file or directory|Connection refused)/i.test(String(e.stderr ?? e.message ?? "")); }
|
|
7406
|
+
export function stopInstanceSession(home, o = {}) {
|
|
7407
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session stop needs an absolute instance home");
|
|
7408
|
+
if (o.graceMs !== undefined && (!Number.isSafeInteger(o.graceMs) || o.graceMs < 1 || o.graceMs > 300000)) throw oatsError("E_BAD_ARGS", "stop grace must be 1-300000 ms");
|
|
7409
|
+
const realHome = realPathOrNearest(home);
|
|
7410
|
+
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing was stopped`);
|
|
7411
|
+
const s = instanceSessionTarget(realHome);
|
|
7412
|
+
if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
|
|
7413
|
+
let before;
|
|
7414
|
+
try { before = inspectSessionTarget(s.target, s.io); }
|
|
7415
|
+
catch (e) { if (s.target.backend === "tmux" && tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
|
|
7416
|
+
if (!before.present || before.state === "shell" || before.state === "stopped") return { home: s.home, backend: s.target.backend, stopped: false, alreadyIdle: true, state: before.state, receipt: null };
|
|
7417
|
+
const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, io: s.io, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
7418
|
+
writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
|
|
7419
|
+
appendEvent(realHome, { kind: receipt.exited ? "stopped" : "stop-refused", data: { signal: receipt.signal, waitedMs: receipt.waitedMs, stillRunning: receipt.stillRunning ?? [], state: receipt.state } });
|
|
7420
|
+
if (!receipt.exited) throw Object.assign(oatsError("E_SESSION_STOP_FAILED", `${basename(realHome)} was asked to stop (${receipt.signal} to ${receipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")}) and is still running after ${receipt.waitedMs} ms; nothing was escalated`), { receipt });
|
|
7421
|
+
return { home: s.home, backend: s.target.backend, stopped: true, alreadyIdle: false, state: receipt.state, receipt };
|
|
7422
|
+
}
|
|
7423
|
+
|
|
7424
|
+
/** Recompose a running home's instructions from its CURRENT canonical soul and
|
|
7425
|
+
* context — an operator-authorized refresh, so a role change can reach a live
|
|
7426
|
+
* instance without a respawn. Composition only: the same composer spawn
|
|
7427
|
+
* used, the same soul the home links to, the home's recorded context/work
|
|
7428
|
+
* mode; the previous AGENTS.md is retained as evidence; the change is
|
|
7429
|
+
* recorded in instance.json (instructions sources) and as a typed event.
|
|
7430
|
+
* Nothing is launched, restarted or signalled — the running process picks the
|
|
7431
|
+
* new text up on its next read (harness-dependent; the receipt says so). */
|
|
7432
|
+
export function recomposeInstanceInstructions(home, { dryRun = false } = {}) {
|
|
7433
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "recompose needs an absolute instance home");
|
|
7434
|
+
const realHome = realPathOrNearest(home);
|
|
7435
|
+
const metaFile = join(realHome, "instance.json");
|
|
7436
|
+
if (!existsSync(metaFile)) throw oatsError("E_SESSION_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json)`);
|
|
7437
|
+
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing recomposed`);
|
|
7438
|
+
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
7439
|
+
if (meta.executionBinding || meta.captured) throw oatsError("E_UNSUPPORTED_MODE", "captured incarnations are recomposed by preparing a new resolution, not in place");
|
|
7440
|
+
const soulLink = join(realHome, "soul");
|
|
7441
|
+
let soulDir; try { soulDir = realpathSync(soulLink); } catch { throw oatsError("E_SOUL_UNKNOWN", `${realHome} has no readable soul link`); }
|
|
7442
|
+
const agentDir = dirname(dirname(realHome));
|
|
7443
|
+
const agent = findAgent(dirname(agentDir), basename(agentDir)) || { name: meta.agent, kind: meta.kind, _dir: agentDir };
|
|
7444
|
+
if (agent.kind === "capability") throw oatsError("E_UNSUPPORTED_MODE", "capability-defined souls are recomposed by updating their package");
|
|
7445
|
+
const contextDir = meta.repo && existsSync(meta.repo) ? meta.repo : dirname(dirname(agentDir));
|
|
7446
|
+
const composition = composeInstanceAgentsMd(soulDir, contextDir, meta.agent, meta.work || "checkout", meta.kind || "persistent");
|
|
7447
|
+
const target = join(realHome, "AGENTS.md");
|
|
7448
|
+
const before = existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
7449
|
+
const changed = before !== composition.text;
|
|
7450
|
+
const result = { home: realHome, instance: meta.instance, agent: meta.agent, soulDir, contextDir, changed, dryRun,
|
|
7451
|
+
blocks: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
7452
|
+
previous: null, note: "the running harness re-reads its instructions on its own schedule (typically the next turn or restart); this refresh does not signal or restart anything" };
|
|
7453
|
+
if (dryRun || !changed) return result;
|
|
7454
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
7455
|
+
const previous = join(realHome, `.oats-agents-md.${stamp}.previous`);
|
|
7456
|
+
if (before !== null) writeFileSync(previous, before);
|
|
7457
|
+
writeFileSync(target, composition.text);
|
|
7458
|
+
meta.instructions = composition.blocks.map((b) => ({ source: b.source, file: b.file }));
|
|
7459
|
+
meta.recomposedAt = new Date().toISOString();
|
|
7460
|
+
writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
|
|
7461
|
+
appendEvent(realHome, { kind: "recomposed", data: { previous, blocks: result.blocks.length, soulDir } });
|
|
7462
|
+
result.previous = previous;
|
|
7463
|
+
return result;
|
|
7464
|
+
}
|
|
7465
|
+
|
|
7278
7466
|
export async function attachInstanceSession(home) {
|
|
7279
7467
|
const s = instanceSessionTarget(home);
|
|
7280
7468
|
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
@@ -7461,8 +7649,12 @@ export function harnessProcesses(target, io) {
|
|
|
7461
7649
|
* running. Elapsed time is never taken as exit. */
|
|
7462
7650
|
export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io = {}, kill = process.kill, sleep } = {}) {
|
|
7463
7651
|
const wait = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
|
|
7464
|
-
|
|
7465
|
-
|
|
7652
|
+
// "Already idle" must be a STABLE reading: the shell-vs-harness decision walks
|
|
7653
|
+
// `ps`, which under load can miss a just-forked descendant for one sample.
|
|
7654
|
+
// One momentary "shell" must never be taken as exit with nothing signalled.
|
|
7655
|
+
const idle = (s) => !s.present || s.state === "shell" || s.state === "stopped";
|
|
7656
|
+
let before = inspectSessionTarget(target, io);
|
|
7657
|
+
if (idle(before)) { wait(250); const again = inspectSessionTarget(target, io); if (idle(again)) return { requested: [], signal, sentAt: null, observedAt: new Date().toISOString(), exited: true, waitedMs: 0, state: again.state, stillRunning: [] }; before = again; }
|
|
7466
7658
|
const procs = harnessProcesses(target, io);
|
|
7467
7659
|
if (!procs.length) throw oatsError("E_SESSION_UNKNOWN", `the session reads as ${before.state} but no harness process was found under its pane; nothing was signalled`);
|
|
7468
7660
|
const requested = [];
|
|
@@ -7473,12 +7665,14 @@ export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io =
|
|
|
7473
7665
|
}
|
|
7474
7666
|
const started = Date.now();
|
|
7475
7667
|
const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
7476
|
-
let st = before;
|
|
7668
|
+
let st = before, idleStreak = 0;
|
|
7477
7669
|
while (Date.now() - started <= graceMs) {
|
|
7478
7670
|
wait(250);
|
|
7479
7671
|
try { st = inspectSessionTarget(target, io); } catch { st = { present: true, state: "unknown" }; }
|
|
7480
7672
|
const gone = requested.every((r) => !alive(r.pid));
|
|
7481
|
-
|
|
7673
|
+
idleStreak = gone && idle(st) ? idleStreak + 1 : 0;
|
|
7674
|
+
// Exit needs two consecutive agreeing samples, not one.
|
|
7675
|
+
if (idleStreak >= 2) return { requested, signal, sentAt, observedAt: new Date().toISOString(), exited: true, waitedMs: Date.now() - started, state: st.state };
|
|
7482
7676
|
}
|
|
7483
7677
|
return { requested, signal, sentAt, observedAt: null, exited: false, waitedMs: Date.now() - started, state: st.state, stillRunning: requested.filter((r) => alive(r.pid)).map((r) => r.pid) };
|
|
7484
7678
|
}
|
|
@@ -7913,7 +8107,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7913
8107
|
const pendingPath = join(realHome, ".oats-start-pending.json");
|
|
7914
8108
|
const exitedPath = join(realHome, ".oats-start-exited");
|
|
7915
8109
|
const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
|
|
7916
|
-
const lostTmuxServer =
|
|
8110
|
+
const lostTmuxServer = tmuxServerLost;
|
|
7917
8111
|
const launchFailure = (backend, error) => {
|
|
7918
8112
|
// execFileSync errors embed argv (including capability environment) in
|
|
7919
8113
|
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
@@ -8074,6 +8268,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8074
8268
|
// as running; nothing is escalated and nothing is launched.
|
|
8075
8269
|
stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
8076
8270
|
writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { runtime: launchPlan?.runtime || runtime, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
|
|
8271
|
+
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
8077
8272
|
if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
|
|
8078
8273
|
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
8079
8274
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
@@ -8783,11 +8978,58 @@ export function retireInstance(root, name, o = {}) {
|
|
|
8783
8978
|
} catch { /* no team scope resolvable — local root only */ }
|
|
8784
8979
|
for (const r2 of roots) scanRoot(r2);
|
|
8785
8980
|
|
|
8981
|
+
// K3b — retention is the default (lifecycle decision §1): the worktree,
|
|
8982
|
+
// branch and any PR outlive the home. The worktree cannot stay under
|
|
8983
|
+
// <home>/work once the home is removed, so it is RE-HOMED with
|
|
8984
|
+
// `git worktree move` to the deployment-level worktrees root and the move is
|
|
8985
|
+
// recorded in the receipt. `discardWorktree` restores removal; branch
|
|
8986
|
+
// deletion uses the VERIFIED current ref of the worktree, never the
|
|
8987
|
+
// spawn-time recorded name. A failed move keeps the home (fail closed).
|
|
8988
|
+
let retention = null;
|
|
8786
8989
|
if (isWorktree && meta.repo) {
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8990
|
+
const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
|
|
8991
|
+
const verifiedBranch = ref.branch;
|
|
8992
|
+
// A branch cannot be deleted while a worktree has it checked out, so
|
|
8993
|
+
// --delete-branch implies discarding the worktree (which is what every
|
|
8994
|
+
// caller of it meant: clean up everything). Plain retire retains.
|
|
8995
|
+
if (o.discardWorktree || o.deleteBranch || quarantine) {
|
|
8996
|
+
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
8997
|
+
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
8998
|
+
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
8999
|
+
} else if (existsSync(workPath)) {
|
|
9000
|
+
const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
|
|
9001
|
+
const leaf = (verifiedBranch ?? `detached-${(ref.oid || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
|
|
9002
|
+
const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
|
|
9003
|
+
mkdirSync(retainedRoot, { recursive: true });
|
|
9004
|
+
let dest = join(retainedRoot, leaf);
|
|
9005
|
+
for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
|
|
9006
|
+
try {
|
|
9007
|
+
execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
9008
|
+
} catch (e) {
|
|
9009
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the worktree at ${workPath} could not be re-homed to ${dest} (${String(e.stderr ?? e.message ?? "").trim()}); the home is kept so nothing is lost — resolve and retry, or pass --discard-worktree to remove the worktree instead`);
|
|
9010
|
+
}
|
|
9011
|
+
retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.oid : null, recordedBranch: meta.branch ?? null };
|
|
9012
|
+
} else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
9013
|
+
if (o.deleteBranch && verifiedBranch) {
|
|
9014
|
+
// A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
|
|
9015
|
+
// the tree during retirement, so the branch is re-verified here, at the
|
|
9016
|
+
// moment of deletion; a mismatch deletes nothing and says so.
|
|
9017
|
+
if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
|
|
9018
|
+
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
|
|
9019
|
+
} else {
|
|
9020
|
+
shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
|
|
9021
|
+
retention.branchDeleted = verifiedBranch;
|
|
9022
|
+
}
|
|
9023
|
+
} else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
|
|
9024
|
+
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
|
|
9025
|
+
}
|
|
9026
|
+
}
|
|
9027
|
+
if (retention) {
|
|
9028
|
+
if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
|
|
9029
|
+
else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
|
|
9030
|
+
if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
|
|
8790
9031
|
}
|
|
9032
|
+
appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null } }, { workspaceOnly: true });
|
|
8791
9033
|
// Retrying a quarantine only clears it if compensation ACTUALLY completed.
|
|
8792
9034
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
8793
9035
|
// retry becomes the deletion the quarantine was preventing.
|
|
@@ -8893,7 +9135,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
8893
9135
|
}
|
|
8894
9136
|
|
|
8895
9137
|
|
|
8896
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(
|
|
9138
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
|
|
8897
9139
|
if (self) {
|
|
8898
9140
|
// The caller is the instance: its process lives in the window we are about to
|
|
8899
9141
|
// kill. Detach the kill so this function can return and the caller can report
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** K7 — typed lifecycle events per instance, append-only, producer-attributed.
|
|
2
|
+
*
|
|
3
|
+
* Every event is a fact a kernel action just made true (spawned, launched,
|
|
4
|
+
* stopped, restarted, retired…), written by the code that did it, with the
|
|
5
|
+
* receipt it produced. Nothing here is inferred from transcripts, task files or
|
|
6
|
+
* prose; "waiting on you" is reported only when a producer says so — today no
|
|
7
|
+
* producer does, and the view says `null`, not a guess. Retirement's event
|
|
8
|
+
* outlives the home in the workspace-level log. */
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const EVENTS_API = 1;
|
|
13
|
+
const HOME_LOG = ".oats-events.jsonl";
|
|
14
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "recomposed"];
|
|
17
|
+
|
|
18
|
+
function workspaceLogPath(home) {
|
|
19
|
+
// <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
|
|
20
|
+
// state beside installed capabilities and schedules; survives the home's removal.
|
|
21
|
+
const agentDir = dirname(dirname(home)); // <root>/<agent>/instances/<instance>
|
|
22
|
+
const workspace = dirname(dirname(agentDir)); // <workspace>/agents/<agent>
|
|
23
|
+
return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Append one typed event. Never throws into the caller's action: an event is
|
|
27
|
+
* evidence, not authority — a failed write is reported in the return value. */
|
|
28
|
+
export function appendEvent(home, event, { workspaceOnly = false } = {}) {
|
|
29
|
+
if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
|
|
30
|
+
const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
31
|
+
const line = JSON.stringify(row) + "\n";
|
|
32
|
+
const results = [];
|
|
33
|
+
// Retirement fingerprints the home against its baseline and preserves any
|
|
34
|
+
// changed bytes as "unknown work": events written DURING retirement go only
|
|
35
|
+
// to the workspace log, never into the home being inspected.
|
|
36
|
+
for (const path of [...(workspaceOnly ? [] : [join(home, HOME_LOG)]), workspaceLogPath(home)]) {
|
|
37
|
+
try {
|
|
38
|
+
if (path.startsWith(home) && !existsSync(home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
appendFileSync(path, line);
|
|
41
|
+
results.push({ path, ok: true });
|
|
42
|
+
} catch (e) { results.push({ path, ok: false, reason: e.message }); }
|
|
43
|
+
}
|
|
44
|
+
return { ok: results.some((r) => r.ok), row, results };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readLog(path) {
|
|
48
|
+
if (!existsSync(path)) return { rows: [], truncated: false, bytes: 0 };
|
|
49
|
+
const size = statSync(path).size;
|
|
50
|
+
const text = readFileSync(path, "utf8");
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const line of text.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r && r.eventsApi === EVENTS_API && typeof r.kind === "string") rows.push(r); } catch { /* a torn line is skipped, counted */ rows.push({ eventsApi: EVENTS_API, kind: "unreadable", at: null, producer: "kernel", data: { reason: "torn or invalid line" } }); } }
|
|
53
|
+
return { rows, truncated: size > MAX_BYTES, bytes: size };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Events for one instance, newest last, from both logs (deduplicated by
|
|
57
|
+
* at+kind), with a bounded window. `waitingOnYou` is a producer claim or null. */
|
|
58
|
+
export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
59
|
+
const a = readLog(join(home, HOME_LOG)), b = readLog(workspaceLogPath(home));
|
|
60
|
+
const seen = new Set(); const rows = [];
|
|
61
|
+
for (const r of [...a.rows, ...b.rows]) { const k = `${r.at}|${r.kind}|${JSON.stringify(r.data ?? null)}`; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
|
|
62
|
+
rows.sort((x, y) => String(x.at ?? "").localeCompare(String(y.at ?? "")));
|
|
63
|
+
const filtered = since ? rows.filter((r) => r.at && r.at > since) : rows;
|
|
64
|
+
const window = filtered.slice(-limit);
|
|
65
|
+
const last = window.at(-1) ?? null;
|
|
66
|
+
const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
|
|
67
|
+
return {
|
|
68
|
+
eventsApi: EVENTS_API, instance: basename(home), home, count: filtered.length, returned: window.length, truncated: filtered.length > window.length || a.truncated || b.truncated,
|
|
69
|
+
events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer } : null,
|
|
70
|
+
waitingOnYou: waiting ? { since: waiting.at, producer: waiting.producer, reason: waiting.data.reason ?? null } : null,
|
|
71
|
+
notes: [
|
|
72
|
+
"events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
|
|
73
|
+
"waitingOnYou is null unless a producer reported it — null means unknown, not 'not waiting'",
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
}
|
package/lib/instance-git.mjs
CHANGED
|
@@ -118,6 +118,26 @@ function blobOf(work, path) {
|
|
|
118
118
|
// Content fingerprint of the working-tree file without writing an object.
|
|
119
119
|
return trim(git(work, ["hash-object", "--no-filters", "--", path], { allowFail: true }));
|
|
120
120
|
}
|
|
121
|
+
/** The branch's upstream remote (else `origin`, else null), with host/path parsed
|
|
122
|
+
* from ssh/https/git forms so a consumer can pick a forge backend WITHOUT running
|
|
123
|
+
* Git itself. Parsing only: no network, no forge knowledge. */
|
|
124
|
+
export function parseRemoteUrl(url) {
|
|
125
|
+
if (typeof url !== "string" || !url) return { host: null, path: null };
|
|
126
|
+
let m = /^(?:ssh:\/\/)?(?:[^@\/]+@)?([^:\/]+)(?::\d+)?[:\/](.+?)(?:\.git)?\/?$/.exec(url);
|
|
127
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
128
|
+
try { const u = new URL(url); m = [null, u.hostname, u.pathname.replace(/^\/+/, "").replace(/\.git$/, "").replace(/\/+$/, "")]; } catch { m = null; }
|
|
129
|
+
}
|
|
130
|
+
if (!m || !m[1] || !m[2]) return { host: null, path: null };
|
|
131
|
+
return { host: m[1].toLowerCase(), path: m[2] };
|
|
132
|
+
}
|
|
133
|
+
function remoteOf(work, branch) {
|
|
134
|
+
const configured = branch ? trim(git(work, ["config", "--get", `branch.${branch}.remote`], { allowFail: true })) : null;
|
|
135
|
+
const name = configured && configured !== "." ? configured : (trim(git(work, ["remote"], { allowFail: true })) ?? "").split("\n").includes("origin") ? "origin" : null;
|
|
136
|
+
if (!name) return null;
|
|
137
|
+
const url = trim(git(work, ["remote", "get-url", name], { allowFail: true }));
|
|
138
|
+
if (!url) return null;
|
|
139
|
+
return { name, url, ...parseRemoteUrl(url), source: configured && configured !== "." ? "branch-upstream" : "origin" };
|
|
140
|
+
}
|
|
121
141
|
function fileId(revision, indexOid, entry) {
|
|
122
142
|
return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
|
|
123
143
|
}
|
|
@@ -153,6 +173,7 @@ export function observeInstanceGit(home) {
|
|
|
153
173
|
observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch.head, detached: branch.head === null && headOid !== null, unborn: headOid === null },
|
|
154
174
|
recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch.head !== meta.branch },
|
|
155
175
|
upstream, base: baseComparison,
|
|
176
|
+
remote: remoteOf(work, branch.head),
|
|
156
177
|
summary, files,
|
|
157
178
|
notes: [
|
|
158
179
|
...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
|