@awebai/oats 0.34.3 → 0.35.0
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 +8 -2
- package/docs/desktop-cli-api.md +32 -16
- package/docs/integrations.md +1 -1
- package/docs/knowledge.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +8 -8
- package/docs/release-notes/v0.34.4.md +51 -0
- package/docs/release-notes/v0.35.0.md +53 -0
- package/docs/souls-and-instances.md +27 -2
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +147 -51
- package/lib/instance-resolution.mjs +23 -9
- package/lib/remote.mjs +3 -1
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/README.md +55 -0
- package/packages/record/bin/capture.mjs +204 -27
- package/packages/record/lib/capture-cc.mjs +10 -4
- package/packages/record/lib/sessions-for-home.mjs +3 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/lib/core.mjs
CHANGED
|
@@ -54,7 +54,8 @@ async function materializePreparedDefault(prepared, home) { const m = await impo
|
|
|
54
54
|
// (manifest, settings, origin, trust; no skills/inject since the copies are not
|
|
55
55
|
// there yet) and REBUILT after materialize against what actually landed. Static
|
|
56
56
|
// import: instance-resolution.mjs does not depend on core.mjs (no cycle).
|
|
57
|
-
import { toCapabilityRows } from "./instance-resolution.mjs";
|
|
57
|
+
import { cloneRemoteFor, toCapabilityRows } from "./instance-resolution.mjs";
|
|
58
|
+
import { GIT_FETCH_TIMEOUT_MS, GIT_TIMEOUT_MS, gitEnv } from "./remote.mjs";
|
|
58
59
|
import { loadLocal } from "./workspace.mjs";
|
|
59
60
|
import { parseConfigData } from "./config-data.mjs";
|
|
60
61
|
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
@@ -3056,7 +3057,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3056
3057
|
// K6: everything a spawn decides is decided by here — and nothing has been
|
|
3057
3058
|
// touched. Branch/base for a worktree are named now (not after mkdir) so the
|
|
3058
3059
|
// preview and the apply agree on them; `o.baseRef` selects the start point.
|
|
3059
|
-
let plannedBranch = null, plannedBase = null;
|
|
3060
|
+
let plannedBranch = null, plannedBase = null, observedBase = null;
|
|
3060
3061
|
if (work === "worktree") {
|
|
3061
3062
|
plannedBranch = o.branch || `agents/${instance}`;
|
|
3062
3063
|
// Validity is Git's own rule (check-ref-format), not a stricter charset:
|
|
@@ -3065,10 +3066,17 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3065
3066
|
try { execFileSync("git", ["check-ref-format", "--branch", plannedBranch], { stdio: ["ignore", "pipe", "pipe"] }); }
|
|
3066
3067
|
catch { throw oatsError("E_BAD_ARGS", `branch ${JSON.stringify(plannedBranch)} is not a valid branch name`); }
|
|
3067
3068
|
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`);
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3069
|
+
// Without --base, a clone of the soul's repository branches from the commit this spawn observed it at
|
|
3070
|
+
// (the one the resolution, and so the decision, binds), never from what the clone has checked out: a
|
|
3071
|
+
// clone is often a human's checkout or a shared reference, far behind (awebai/oats#445).
|
|
3072
|
+
observedBase = o.baseRef ? null : observedSoulBase(o.prepared, repoAbs);
|
|
3073
|
+
if (observedBase) plannedBase = { ref: observedBase.key, oid: observedBase.oid };
|
|
3074
|
+
else {
|
|
3075
|
+
const baseRef = o.baseRef || "HEAD";
|
|
3076
|
+
const baseOid = shInTry(repoAbs, `git rev-parse --verify --quiet ${shq(baseRef + "^{commit}")}`);
|
|
3077
|
+
if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
|
|
3078
|
+
plannedBase = { ref: baseRef, oid: baseOid };
|
|
3079
|
+
}
|
|
3072
3080
|
}
|
|
3073
3081
|
// The decision a confirmation binds: placement AND what would actually
|
|
3074
3082
|
// launch (inherited defaults re-resolved at apply must not drift silently).
|
|
@@ -3132,6 +3140,9 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3132
3140
|
const fresh = buildDecision();
|
|
3133
3141
|
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 });
|
|
3134
3142
|
}
|
|
3143
|
+
// The observed base must be in the clone before anything is placed; a commit that cannot be fetched
|
|
3144
|
+
// refuses the spawn, never falling back to the clone's own branch.
|
|
3145
|
+
if (observedBase) fetchObservedBase(repoAbs, observedBase);
|
|
3135
3146
|
// Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
|
|
3136
3147
|
// an absent tmux binary must fail with nothing created, never after a
|
|
3137
3148
|
// populated home exists.
|
|
@@ -3668,7 +3679,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3668
3679
|
const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
|
|
3669
3680
|
const meta = {
|
|
3670
3681
|
agent: agent.name, kind: agent.kind || "persistent", instance, home, soulDir: homeSoulTarget,
|
|
3671
|
-
repo: repoAbs, work, branch, harness, model: model || undefined, modelFrom: modelFromOf(launchSelection.modelSource, { at: "spawn" }) ?? undefined,
|
|
3682
|
+
repo: repoAbs, work, branch, ...(plannedBase ? { base: plannedBase } : {}), harness, model: model || undefined, modelFrom: modelFromOf(launchSelection.modelSource, { at: "spawn" }) ?? undefined,
|
|
3672
3683
|
// Feature launch-preference: the layer that decided this launch, and the soul's own preference then.
|
|
3673
3684
|
launchFrom: launchChoice.from, launchAt: launchChoice.at, launchDeclared: launchChoice.declared,
|
|
3674
3685
|
...(yolo !== undefined ? { yolo } : {}),
|
|
@@ -4108,6 +4119,36 @@ function sessionDirectoryGuard(home) {
|
|
|
4108
4119
|
return check;
|
|
4109
4120
|
}
|
|
4110
4121
|
|
|
4122
|
+
/** The commit a worktree spawn of a workspace soul observed its repository at, when `repoAbs` is a clone of
|
|
4123
|
+
* that repository: { key, oid, remote } (the clone's remote that names it), else null (a package soul, or a
|
|
4124
|
+
* --repo that is not a clone of the soul's repository: the clone's HEAD stays the base). */
|
|
4125
|
+
function observedSoulBase(prepared, repoAbs) {
|
|
4126
|
+
const entry = prepared?.soulEntry;
|
|
4127
|
+
if (!entry || typeof entry.package === "string" || typeof entry.repoKey !== "string") return null;
|
|
4128
|
+
const oid = entry.memberCommit ?? entry.commit;
|
|
4129
|
+
if (typeof oid !== "string" || !/^[0-9a-f]{40,64}$/.test(oid)) return null;
|
|
4130
|
+
const remote = cloneRemoteFor(repoAbs, entry.repoKey);
|
|
4131
|
+
return remote ? { key: entry.repoKey, oid, remote } : null;
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
/** Make the observed base present in the clone: fetched by id from the remote that names the soul's
|
|
4135
|
+
* repository, never prompting (remote.mjs's git environment), and touching no branch, remote-tracking ref,
|
|
4136
|
+
* FETCH_HEAD or work tree of the clone. */
|
|
4137
|
+
function fetchObservedBase(repoAbs, { key, oid, remote }) {
|
|
4138
|
+
const env = gitEnv();
|
|
4139
|
+
const present = () => spawnSync("git", ["-C", repoAbs, "cat-file", "-e", `${oid}^{commit}`], { stdio: "ignore", env, timeout: GIT_TIMEOUT_MS }).status === 0;
|
|
4140
|
+
if (present()) return;
|
|
4141
|
+
const r = spawnSync("git", ["-C", repoAbs, "fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--end-of-options", remote, oid],
|
|
4142
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env, timeout: GIT_FETCH_TIMEOUT_MS });
|
|
4143
|
+
if (r.status === 0 && present()) return;
|
|
4144
|
+
const lines = String(r.stderr || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
4145
|
+
const why = r.error?.code === "ETIMEDOUT" ? "timeout" : (lines.find((l) => /^(fatal|error):/.test(l)) ?? lines[0] ?? `git fetch exited ${r.status ?? r.signal}`);
|
|
4146
|
+
// A server that only serves advertised refs (protocol v0 without allowAnySHA1InWant) refuses a commit its
|
|
4147
|
+
// branches have moved past, and the fetch by hand fails the same way: --base is then the way on.
|
|
4148
|
+
throw Object.assign(oatsError("E_REMOTE_UNREADABLE", `cannot fetch commit ${oid.slice(0, 12)} of ${key} into the clone ${repoAbs} from its remote ${remote} (${why}); the instance branch starts at the commit this spawn observed, never at the clone's own branch — fetch it (git -C ${shq(repoAbs)} fetch --end-of-options ${shq(remote)} ${oid}) or name a start point with --base; nothing was created`),
|
|
4149
|
+
{ details: { repo: repoAbs, repoKey: key, commit: oid, remote } });
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4111
4152
|
/** A failed spawn's compare-and-delete of the branch it created at `oid`: `git update-ref -d` refuses
|
|
4112
4153
|
* atomically if the tip moved (something was committed there), and the branch is then kept. `run`
|
|
4113
4154
|
* answers { ok, out, status, err }. → what is still owed, as messages ([] when the branch is gone). */
|
|
@@ -4121,7 +4162,7 @@ function deleteBranchAsCreated(run, repoAbs, branch, oid) {
|
|
|
4121
4162
|
}
|
|
4122
4163
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
4123
4164
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
4124
|
-
function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
|
|
4165
|
+
function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason, directoryPreservation = false, orphanedWork = false, directoryHome = realPathOrNearest(home) }) {
|
|
4125
4166
|
const marker = {
|
|
4126
4167
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
4127
4168
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
@@ -4141,7 +4182,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
4141
4182
|
// instance.json may be only the pre-hook stub, which records neither.
|
|
4142
4183
|
...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
|
|
4143
4184
|
...(typeof soulId === "string" && soulId ? { soulId } : {}),
|
|
4144
|
-
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
4185
|
+
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}), ...(orphanedWork ? { orphanedWork: true } : {}) },
|
|
4145
4186
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
4146
4187
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
|
|
4147
4188
|
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
@@ -4212,7 +4253,11 @@ function usableCleanupDescriptor(marker) {
|
|
|
4212
4253
|
if (c.capabilityMeta !== undefined && !isPlainObject(c.capabilityMeta)) return false;
|
|
4213
4254
|
const directoryDebt = c.outstanding?.directory === true && c.work === "directory";
|
|
4214
4255
|
if (c.outstanding?.directory !== undefined && !directoryDebt) return false;
|
|
4215
|
-
|
|
4256
|
+
// A worktree directory whose git admin entry is gone (awebai/oats#444) is debt too: the retry proves it
|
|
4257
|
+
// cleared when the directory is no longer there or has its admin entry back.
|
|
4258
|
+
const orphanDebt = c.outstanding?.orphanedWork === true && c.work === "worktree";
|
|
4259
|
+
if (c.outstanding?.orphanedWork !== undefined && !orphanDebt) return false;
|
|
4260
|
+
if (!Array.isArray(c.capabilityRuntime) || (!c.capabilityRuntime.length && !directoryDebt && !orphanDebt)) return false;
|
|
4216
4261
|
if (!c.capabilityRuntime.every((cap) => isPlainObject(cap) && nonEmptyString(cap.id))) return false;
|
|
4217
4262
|
if (!isPlainObject(c.outstanding) || !Array.isArray(c.outstanding.hooks) || !Array.isArray(c.outstanding.git)) return false;
|
|
4218
4263
|
if (!c.outstanding.hooks.every(nonEmptyString)) return false;
|
|
@@ -4224,7 +4269,7 @@ function usableCleanupDescriptor(marker) {
|
|
|
4224
4269
|
// obligation of zero. Directory preservation is also real debt: the retry's
|
|
4225
4270
|
// independent-authority inspection and verified snapshots must succeed even
|
|
4226
4271
|
// when no capability has a retire hook. It is never a Git/shared-work escape.
|
|
4227
|
-
if (!c.outstanding.hooks.length && !c.outstanding.git.length && !directoryDebt) return false;
|
|
4272
|
+
if (!c.outstanding.hooks.length && !c.outstanding.git.length && !directoryDebt && !orphanDebt) return false;
|
|
4228
4273
|
// The retry must be ABLE to rerun what it must prove: an outstanding hook whose
|
|
4229
4274
|
// capability is not in the set could never run, so the quarantine would never
|
|
4230
4275
|
// clear — and the home would be unremovable without --force.
|
|
@@ -4301,6 +4346,21 @@ function worktreeRef(work) {
|
|
|
4301
4346
|
try { branch = execFileSync("git", ["-C", work, "symbolic-ref", "--quiet", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim() || null; } catch { branch = null; }
|
|
4302
4347
|
return { branch, oid };
|
|
4303
4348
|
}
|
|
4349
|
+
/** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
|
|
4350
|
+
* admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
|
|
4351
|
+
* whatever repository encloses the home. A `.git` directory is a repository of its own, not this case. */
|
|
4352
|
+
function worktreeAdminMissing(work) {
|
|
4353
|
+
const dotGit = join(work, ".git");
|
|
4354
|
+
let stat;
|
|
4355
|
+
try { stat = lstatSync(dotGit); } catch { return true; }
|
|
4356
|
+
if (stat.isDirectory()) return false;
|
|
4357
|
+
if (!stat.isFile()) return true;
|
|
4358
|
+
let text;
|
|
4359
|
+
try { text = readFileSync(dotGit, "utf8"); } catch { return true; }
|
|
4360
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(text);
|
|
4361
|
+
if (!m) return true;
|
|
4362
|
+
return !existsSync(join(resolve(work, m[1]), "HEAD"));
|
|
4363
|
+
}
|
|
4304
4364
|
function worktreeStatus(repo) {
|
|
4305
4365
|
try {
|
|
4306
4366
|
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
@@ -5172,7 +5232,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5172
5232
|
}
|
|
5173
5233
|
}
|
|
5174
5234
|
|
|
5175
|
-
function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false } = {}) {
|
|
5235
|
+
function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false, orphanedWork = false } = {}) {
|
|
5176
5236
|
if (directory) assertDirectoryRoots(home);
|
|
5177
5237
|
const classes = [];
|
|
5178
5238
|
let baseline;
|
|
@@ -5213,7 +5273,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5213
5273
|
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
5214
5274
|
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
5215
5275
|
.digest("hex");
|
|
5216
|
-
return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
5276
|
+
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
5217
5277
|
}
|
|
5218
5278
|
|
|
5219
5279
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -5418,9 +5478,11 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5418
5478
|
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
5419
5479
|
// In-progress Git operations retain the full standalone recovery even
|
|
5420
5480
|
// when porcelain status has no changed paths.
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5481
|
+
// An orphaned work directory (no git admin entry) stays where it is, never moved or removed, and git
|
|
5482
|
+
// cannot read it: only the home is snapshotted.
|
|
5483
|
+
let homeOnly = observation.orphanedWork === true || (observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
|
|
5484
|
+
&& meta.work === "worktree" && existsSync(observation.work));
|
|
5485
|
+
if (homeOnly && !observation.orphanedWork) {
|
|
5424
5486
|
const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5425
5487
|
homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
5426
5488
|
}
|
|
@@ -5724,10 +5786,20 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5724
5786
|
if (self && (!o.keepDir || herdrHome)) {
|
|
5725
5787
|
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
5726
5788
|
}
|
|
5789
|
+
// A worktree directory whose git admin entry is gone cannot be inspected, moved or removed through git, and
|
|
5790
|
+
// what it holds may exist nowhere else: it is never removed. Without --force the hooks still run and it is
|
|
5791
|
+
// an incomplete item; --force (which removes the home it sits in) refuses before anything runs.
|
|
5792
|
+
const orphanedWork = meta.work === "worktree" && existsSync(workPath) && worktreeAdminMissing(workPath);
|
|
5793
|
+
if (orphanedWork && o.force) {
|
|
5794
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the work directory ${workPath} has no git admin entry in ${meta.repo ?? "its repository"}, so retiring the home would delete it with whatever it holds; move it out or delete it by hand, then retire again; nothing was run or removed`);
|
|
5795
|
+
}
|
|
5796
|
+
const orphanItem = orphanedWork ? `git worktree ${workPath}: its admin entry is missing; the directory is kept — move it out or delete it by hand, then retire again` : null;
|
|
5797
|
+
const inspectableWorktree = isWorktree && !orphanedWork;
|
|
5727
5798
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
5728
5799
|
// the managed harness; recovery copying never races a live managed Pi.
|
|
5800
|
+
const owesWorktree = !!quarantine && (quarantine.cleanup.outstanding?.git || []).includes("worktree");
|
|
5729
5801
|
const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
|
|
5730
|
-
const initialObservation = inspectRetirementWork(found.home, workPath,
|
|
5802
|
+
const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5731
5803
|
// Harness identity is destructive authority. The mutable child metadata may
|
|
5732
5804
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5733
5805
|
// endpoint that proves quiescence.
|
|
@@ -5773,7 +5845,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5773
5845
|
if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${detail || "tmux inspection failed"}`);
|
|
5774
5846
|
}
|
|
5775
5847
|
}
|
|
5776
|
-
const stableObservation = inspectRetirementWork(found.home, workPath,
|
|
5848
|
+
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5777
5849
|
const workRecoveries = [];
|
|
5778
5850
|
if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
|
|
5779
5851
|
let workRecovery = workRecoveries.at(-1);
|
|
@@ -5821,11 +5893,38 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5821
5893
|
ordinaryIncomplete.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""} — external state may remain`);
|
|
5822
5894
|
}
|
|
5823
5895
|
}
|
|
5896
|
+
if (orphanItem) ordinaryIncomplete.push(orphanItem);
|
|
5897
|
+
}
|
|
5898
|
+
// A quarantine retry's outcome apart from Git: what the hooks it reran left undone, and what they owed and
|
|
5899
|
+
// did not run. Known before the worktree step, which waits for it.
|
|
5900
|
+
const retryFailures = [];
|
|
5901
|
+
if (quarantine) {
|
|
5902
|
+
for (const f of hookResults?.failures || []) retryFailures.push(`retire hook ${f.capability}: ${f.message}`);
|
|
5903
|
+
for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
|
|
5904
|
+
if (m && typeof m === "object" && m.retired === false && m.reason !== "nothing-to-delete") {
|
|
5905
|
+
retryFailures.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""}`);
|
|
5906
|
+
}
|
|
5907
|
+
}
|
|
5908
|
+
// The decisive check: not "did anything fail" but "did the work that was
|
|
5909
|
+
// outstanding actually happen". A retry that resolves zero capabilities —
|
|
5910
|
+
// because the descriptor named none, or config drifted since the spawn —
|
|
5911
|
+
// otherwise reports a clean sweep it never performed, and the home and its
|
|
5912
|
+
// credential go with it (reviewer-dd03a98).
|
|
5913
|
+
const ran = new Set(hookResults?.order || []);
|
|
5914
|
+
for (const capId of quarantine.cleanup.outstanding?.hooks || []) {
|
|
5915
|
+
if (ran.has(capId)) continue;
|
|
5916
|
+
const cap = (meta.capabilityRuntime || []).find((c) => c.id === capId);
|
|
5917
|
+
retryFailures.push(cap && !cap.hooks?.retire
|
|
5918
|
+
? `${capId}: declares no retire hook, so OATS cannot verify or undo what its failed spawn hook may have created — clean up by hand, then remove the home with \`oats retire ${name} --force\``
|
|
5919
|
+
: `retire hook ${capId}: did not run on this retry, so the cleanup it owed is unverified`);
|
|
5920
|
+
}
|
|
5921
|
+
if (!hookResults) retryFailures.push("retire hooks could not be rerun (cleanup descriptor lost its context repo)");
|
|
5922
|
+
if (orphanItem) retryFailures.push(orphanItem);
|
|
5824
5923
|
}
|
|
5825
5924
|
|
|
5826
5925
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
5827
5926
|
// them and preserve a separately verified post-hook snapshot when needed.
|
|
5828
|
-
const finalObservation = inspectRetirementWork(found.home, workPath,
|
|
5927
|
+
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5829
5928
|
if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
|
|
5830
5929
|
workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
|
|
5831
5930
|
workRecovery = workRecoveries.at(-1);
|
|
@@ -5890,14 +5989,23 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5890
5989
|
// recorded in the receipt. `discardWorktree` restores removal; branch
|
|
5891
5990
|
// deletion uses the VERIFIED current ref of the worktree, never the
|
|
5892
5991
|
// spawn-time recorded name. A failed move keeps the home (fail closed).
|
|
5992
|
+
// The worktree step runs only once nothing else is outstanding (awebai/oats#444): a hook that did not finish
|
|
5993
|
+
// may need the worktree, and its retry needs the home, the worktree and its admin entry exactly as they
|
|
5994
|
+
// were. --force removes the home regardless, so the step runs first and no admin entry is left dangling.
|
|
5995
|
+
// A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
|
|
5996
|
+
// --discard-worktree or --delete-branch. An orphaned work directory is never touched.
|
|
5997
|
+
const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
|
|
5998
|
+
const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
|
|
5999
|
+
const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
|
|
6000
|
+
const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
|
|
5893
6001
|
let retention = null;
|
|
5894
|
-
if (
|
|
6002
|
+
if (worktreeStep && !worktreeDeferred) {
|
|
5895
6003
|
const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
|
|
5896
6004
|
const verifiedBranch = ref.branch;
|
|
5897
6005
|
// A branch cannot be deleted while a worktree has it checked out, so
|
|
5898
6006
|
// --delete-branch implies discarding the worktree (which is what every
|
|
5899
6007
|
// caller of it meant: clean up everything). Plain retire retains.
|
|
5900
|
-
if (o.discardWorktree || o.deleteBranch ||
|
|
6008
|
+
if (o.discardWorktree || o.deleteBranch || owesWorktree) {
|
|
5901
6009
|
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
5902
6010
|
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
5903
6011
|
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
@@ -5947,6 +6055,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5947
6055
|
// spawn rollback uses. Two copies of this logic is how a previous divergence
|
|
5948
6056
|
// happened (see quarantineInstanceHome), so there is still exactly one.
|
|
5949
6057
|
if (!quarantine && !self && ordinaryIncomplete.length) {
|
|
6058
|
+
if (keptForRetry) ordinaryIncomplete.push(keptForRetry);
|
|
5950
6059
|
const outstandingHooks = new Set();
|
|
5951
6060
|
for (const f of hookResults?.failures || []) outstandingHooks.add(f.capability);
|
|
5952
6061
|
for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
|
|
@@ -5960,12 +6069,14 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5960
6069
|
repoAbs: meta.repo, work: meta.work, branch: meta.branch,
|
|
5961
6070
|
resolvedCfg: { capabilities: meta.capabilityRuntime || [] },
|
|
5962
6071
|
hookMeta: meta.capabilityMeta || {}, compensationMeta: hookResults?.meta || {},
|
|
5963
|
-
|
|
6072
|
+
orphanedWork: !!orphanedWork,
|
|
6073
|
+
reason: outstandingHooks.size ? "retire hook reported incomplete cleanup" : "the work directory has no git admin entry",
|
|
5964
6074
|
});
|
|
5965
6075
|
stillIncomplete = ordinaryIncomplete;
|
|
5966
6076
|
}
|
|
5967
6077
|
if (quarantine) {
|
|
5968
|
-
const failures =
|
|
6078
|
+
const failures = [...retryFailures];
|
|
6079
|
+
if (keptForRetry) failures.push(keptForRetry);
|
|
5969
6080
|
// The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
|
|
5970
6081
|
// redo those steps and verify them — not just rerun hooks. A branch is never
|
|
5971
6082
|
// deleted here: only --delete-branch deletes one (the verified branch, above).
|
|
@@ -5974,14 +6085,18 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5974
6085
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
5975
6086
|
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5976
6087
|
};
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
const
|
|
5984
|
-
if (
|
|
6088
|
+
// A worktree this retry removed (the step above ran) must be verified gone; one it retained or kept for
|
|
6089
|
+
// the next retry stays registered by design.
|
|
6090
|
+
if (retention?.worktree === "removed") {
|
|
6091
|
+
const wtCanonical = realPathOrNearest(workPath);
|
|
6092
|
+
gitProbe(["git", "-C", meta.repo, "worktree", "remove", "--force", workPath]);
|
|
6093
|
+
gitProbe(["git", "-C", meta.repo, "worktree", "prune"]);
|
|
6094
|
+
const wtProbe = gitProbe(["git", "-C", meta.repo, "worktree", "list", "--porcelain", "-z"]);
|
|
6095
|
+
if (!wtProbe.ok) failures.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`);
|
|
6096
|
+
else {
|
|
6097
|
+
const registered = wtProbe.out.split("\0").filter((f) => f.startsWith("worktree ")).map((f) => f.slice("worktree ".length));
|
|
6098
|
+
if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
|
|
6099
|
+
}
|
|
5985
6100
|
}
|
|
5986
6101
|
// The branch is a debt only when the failed spawn's rollback still owes its deletion, or when the
|
|
5987
6102
|
// operator asked for it (--delete-branch); then it must be verified gone, and a branch kept is said.
|
|
@@ -6004,30 +6119,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6004
6119
|
: `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
|
|
6005
6120
|
}
|
|
6006
6121
|
}
|
|
6007
|
-
for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
|
|
6008
|
-
if (m && typeof m === "object" && m.retired === false && m.reason !== "nothing-to-delete") {
|
|
6009
|
-
failures.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""}`);
|
|
6010
|
-
}
|
|
6011
|
-
}
|
|
6012
|
-
// The decisive check: not "did anything fail" but "did the work that was
|
|
6013
|
-
// outstanding actually happen". A retry that resolves zero capabilities —
|
|
6014
|
-
// because the descriptor named none, or config drifted since the spawn —
|
|
6015
|
-
// otherwise reports a clean sweep it never performed, and the home and its
|
|
6016
|
-
// credential go with it (reviewer-dd03a98).
|
|
6017
|
-
const ran = new Set(hookResults?.order || []);
|
|
6018
6122
|
// Git debt is proven by the verification block above, which only runs for a
|
|
6019
6123
|
// worktree in a known repo. If it could not run, the debt stands.
|
|
6020
6124
|
if (quarantine.cleanup.outstanding?.git?.length && !(meta.work === "worktree" && meta.repo)) {
|
|
6021
6125
|
failures.push(`git ${quarantine.cleanup.outstanding.git.join(", ")}: not re-verified on this retry, so the cleanup they owed is unverified`);
|
|
6022
6126
|
}
|
|
6023
|
-
for (const capId of quarantine.cleanup.outstanding?.hooks || []) {
|
|
6024
|
-
if (ran.has(capId)) continue;
|
|
6025
|
-
const cap = (meta.capabilityRuntime || []).find((c) => c.id === capId);
|
|
6026
|
-
failures.push(cap && !cap.hooks?.retire
|
|
6027
|
-
? `${capId}: declares no retire hook, so OATS cannot verify or undo what its failed spawn hook may have created — clean up by hand, then remove the home with \`oats retire ${name} --force\``
|
|
6028
|
-
: `retire hook ${capId}: did not run on this retry, so the cleanup it owed is unverified`);
|
|
6029
|
-
}
|
|
6030
|
-
if (!hookResults) failures.push("retire hooks could not be rerun (cleanup descriptor lost its context repo)");
|
|
6031
6127
|
if (failures.length) {
|
|
6032
6128
|
stillIncomplete = failures;
|
|
6033
6129
|
try {
|
|
@@ -6053,7 +6149,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6053
6149
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6054
6150
|
}
|
|
6055
6151
|
|
|
6056
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention
|
|
6152
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted), 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: (() => {
|
|
6057
6153
|
const w = [...(hookResults?.warnings || [])];
|
|
6058
6154
|
if (isCapturedHome(meta) && !quarantine) {
|
|
6059
6155
|
// A captured home retires through the workspace path; its captured retire hooks do not
|
|
@@ -339,22 +339,36 @@ function canonicalCloneKey(written) {
|
|
|
339
339
|
return s;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
/** The
|
|
343
|
-
* repo
|
|
344
|
-
function
|
|
342
|
+
/** The remotes a clone carries (any remote, not only origin), each with its url parsed
|
|
343
|
+
* to a repo key: [{ name, key }]. Not a git repo → null. */
|
|
344
|
+
function cloneRemotes(path) {
|
|
345
345
|
const git = (argv) => spawnSync("git", ["-C", path, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
346
346
|
const inside = git(["rev-parse", "--git-dir"]);
|
|
347
347
|
if (inside.status !== 0) return null;
|
|
348
|
-
|
|
349
|
-
const
|
|
348
|
+
// -z: each entry is `<key>\n<value>\0`, so a value holding a newline cannot read as another entry.
|
|
349
|
+
const cfg = git(["config", "-z", "--get-regexp", "^remote\\..*\\.url$"]);
|
|
350
|
+
const remotes = [];
|
|
350
351
|
if (cfg.status === 0) {
|
|
351
|
-
for (const
|
|
352
|
-
const
|
|
352
|
+
for (const entry of cfg.stdout.split("\0")) {
|
|
353
|
+
const nl = entry.indexOf("\n");
|
|
354
|
+
if (nl < 0) continue;
|
|
355
|
+
const key = entry.slice(0, nl), url = entry.slice(nl + 1).trim();
|
|
353
356
|
if (!url) continue;
|
|
354
|
-
|
|
357
|
+
const name = key.slice("remote.".length, -".url".length);
|
|
358
|
+
try { remotes.push({ name, key: parseRepoRef(url).key }); } catch { remotes.push({ name, key: `?/${url}` }); }
|
|
355
359
|
}
|
|
356
360
|
}
|
|
357
|
-
return
|
|
361
|
+
return remotes;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The remote urls a clone carries, parsed to their repo keys. Not a git repo → null. */
|
|
365
|
+
function cloneRemoteKeys(path) {
|
|
366
|
+
return cloneRemotes(path)?.map((r) => r.key) ?? null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The name of the clone's remote whose url names `key`, or null (no such remote, or not a git repo). */
|
|
370
|
+
export function cloneRemoteFor(path, key) {
|
|
371
|
+
return (cloneRemotes(path) || []).find((r) => sameRepoKey(r.key, key))?.name ?? null;
|
|
358
372
|
}
|
|
359
373
|
|
|
360
374
|
/** Two repo keys name the same repository. Hosted keys compare literally; `local/<abs>`
|
package/lib/remote.mjs
CHANGED
|
@@ -293,7 +293,9 @@ export function sshCommand() {
|
|
|
293
293
|
return sshCommandCache;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
/** The environment every git child of the kernel that may reach a remote runs under: it never prompts (no
|
|
297
|
+
* terminal prompt, askpass refused, ssh in BatchMode) and never fetches a missing object on its own. */
|
|
298
|
+
export function gitEnv() {
|
|
297
299
|
return {
|
|
298
300
|
...process.env,
|
|
299
301
|
GIT_TERMINAL_PROMPT: "0",
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -249,3 +249,58 @@ source custody. The library alternative is `sessionsForHome(home, { roots })`:
|
|
|
249
249
|
unspecified formats are excluded, and missing supplied roots fail. Synthetic
|
|
250
250
|
standalone tests must choose one of these explicitly, not masquerade as a
|
|
251
251
|
managed native launch. Background capture without `--home` is unchanged.
|
|
252
|
+
|
|
253
|
+
## One session file: `capture --file`
|
|
254
|
+
|
|
255
|
+
```text
|
|
256
|
+
capture --file <path> --format cc|pi|codex --home <instance home> [--owner <name>] [--json]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Captures ONE session file, for example an archived session that the
|
|
260
|
+
`--home` sweep can no longer find, exactly as `--home` capture of that
|
|
261
|
+
instance would capture it. It runs under the capture lock, as a final pass,
|
|
262
|
+
with the ignore rules applied. The stream is `<owner>~<source>.<session id>`,
|
|
263
|
+
the same identity `--home` capture writes. So the same session captured
|
|
264
|
+
either way, or again with `--file`, appends nothing.
|
|
265
|
+
|
|
266
|
+
- **The owner is explicit.** It is `--owner` or `TURN_RECORD_OWNER`; the
|
|
267
|
+
hostname is never assumed. `--home` must be an OATS instance home (an
|
|
268
|
+
`instance.json` naming an instance). It is recorded in the receipt, not
|
|
269
|
+
checked against the file's recorded cwd.
|
|
270
|
+
- **The format is stated, never sniffed.** The file must carry that format's
|
|
271
|
+
session header somewhere in it:
|
|
272
|
+
- `cc`: a record with a `cwd`, and no other format's session header anywhere
|
|
273
|
+
in the file (a cc transcript never holds one);
|
|
274
|
+
- `pi`: the `session` record;
|
|
275
|
+
- `codex`: `session_meta`.
|
|
276
|
+
- **The session id comes from the file name**, as for live capture: the cc
|
|
277
|
+
basename, the part of a pi name after its last `_`, or a codex name's
|
|
278
|
+
trailing uuid. A renamed file lands in another stream.
|
|
279
|
+
- **One regular file, read once.** It is opened with `O_NOFOLLOW` and
|
|
280
|
+
`O_NONBLOCK` and fstat'ed. A symlink, FIFO, socket, device or directory is
|
|
281
|
+
refused. The bytes read from that descriptor are the ones captured and
|
|
282
|
+
hashed.
|
|
283
|
+
|
|
284
|
+
`--json` prints the receipt:
|
|
285
|
+
`{home, owner, file, format, instance, thread, stream, sessionId, turns,
|
|
286
|
+
firstTurnId, lastTurnId, appended, skipped, held, incomplete, failed, ignored,
|
|
287
|
+
status, complete, sha256, issues?}`. `sha256` is of the bytes captured;
|
|
288
|
+
`issues` (`[{source, path, reason, offset}]`, present when the result is
|
|
289
|
+
incomplete or held) says why. As for
|
|
290
|
+
`--home`, `complete` is true only with no hold, no incomplete tail and no
|
|
291
|
+
failure. A lock skip, a hold (no timestamp yet) and an incomplete tail (torn
|
|
292
|
+
or invalid UTF-8) exit 0 with `complete: false`. Without `--json`, the output
|
|
293
|
+
is one line.
|
|
294
|
+
|
|
295
|
+
Anything that binds nothing is an error,
|
|
296
|
+
`{status: "failed", complete: false, code, error}`:
|
|
297
|
+
|
|
298
|
+
| code | exit | when |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| `E_USAGE` | 2 | a missing or invalid flag, a non-instance `--home`, no explicit owner, a second `--file`, another mode |
|
|
301
|
+
| `E_FILE_UNREADABLE` | 1 | the file cannot be opened or read |
|
|
302
|
+
| `E_NOT_REGULAR_FILE` | 1 | a symlink, FIFO, socket, device or directory |
|
|
303
|
+
| `E_IGNORED` | 1 | a capture ignore rule excludes the file; nothing was opened, read or written |
|
|
304
|
+
| `E_NO_TURNS` | 1 | no records: an empty file, or only blank lines |
|
|
305
|
+
| `E_FORMAT` | 1 | records, but no session header of the stated format; the message names the format whose header it does carry |
|
|
306
|
+
| `E_CAPTURE_FAILED` | 1 | the pass failed (`appended: null`: part of the file may be in the record) |
|