@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/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
- const baseRef = o.baseRef || "HEAD";
3069
- const baseOid = shInTry(repoAbs, `git rev-parse --verify --quiet ${shq(baseRef + "^{commit}")}`);
3070
- if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
3071
- plannedBase = { ref: baseRef, oid: baseOid };
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
- if (!Array.isArray(c.capabilityRuntime) || (!c.capabilityRuntime.length && !directoryDebt)) return false;
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
- let homeOnly = observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
5422
- && meta.work === "worktree" && existsSync(observation.work);
5423
- if (homeOnly) {
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, isWorktree, { branchDeletion, directory });
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, isWorktree, { branchDeletion, directory });
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, isWorktree, { branchDeletion, directory });
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 (isWorktree && meta.repo) {
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 || quarantine) {
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
- reason: "retire hook reported incomplete cleanup",
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 = (hookResults?.failures || []).map((f) => `retire hook ${f.capability}: ${f.message}`);
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
- const wtCanonical = realPathOrNearest(workPath);
5978
- gitProbe(["git", "-C", meta.repo, "worktree", "remove", "--force", workPath]);
5979
- gitProbe(["git", "-C", meta.repo, "worktree", "prune"]);
5980
- const wtProbe = gitProbe(["git", "-C", meta.repo, "worktree", "list", "--porcelain", "-z"]);
5981
- if (!wtProbe.ok) failures.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`);
5982
- else {
5983
- const registered = wtProbe.out.split("\0").filter((f) => f.startsWith("worktree ")).map((f) => f.slice("worktree ".length));
5984
- if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
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?.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: (() => {
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 remote urls a clone carries (any remote, not only origin), parsed to their
343
- * repo keys. Not a git repo → null. */
344
- function cloneRemoteKeys(path) {
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
- const cfg = git(["config", "--get-regexp", "^remote\\..*\\.url$"]);
349
- const keys = [];
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 line of cfg.stdout.split("\n")) {
352
- const url = line.replace(/^\S+\s+/, "").trim();
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
- try { keys.push(parseRepoRef(url).key); } catch { keys.push(`?/${url}`); }
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 keys;
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
- function gitEnv() {
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",
@@ -3,7 +3,7 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v4.0.7",
6
+ "ref": "v4.1.0",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.34.3",
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) |