@awebai/oats 0.34.3 → 0.34.4

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 CHANGED
@@ -2256,6 +2256,8 @@ async function spawnCmd() {
2256
2256
  if (e?.code === "E_REQUIREMENT_INACTIVE") { bail(e.code, e.message, { soul: e.soul, capabilities: e.capabilities, context: e.context, remedy: e.remedy }); throw e; }
2257
2257
  if (e?.code === "E_CHILD_SPAWNS_DISABLED") { bail(e.code, e.message, { parent: e.parent, policy: e.policy }); throw e; }
2258
2258
  if (["E_BRANCH_EXISTS", "E_BASE_UNKNOWN"].includes(e?.code)) { bail(e.code, e.message); throw e; }
2259
+ // The observed base could not be fetched into the clone: the clone, the repository and the commit travel along.
2260
+ if (e?.code === "E_REMOTE_UNREADABLE" && e.details?.commit) { bail(e.code, e.message, e.details); throw e; }
2259
2261
  // K6b: the confirmed decision drifted — the fresh decision travels with the refusal so a GUI re-previews.
2260
2262
  if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
2261
2263
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
@@ -2283,7 +2285,7 @@ async function spawnCmd() {
2283
2285
  // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
2284
2286
  jsonOk({
2285
2287
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2286
- branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2288
+ branch: r.branch || null, base: r.base ?? null, launched: r.launched, warnings: r.warnings || [],
2287
2289
  ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2288
2290
  tmux: r.tmux || null, backend: "tmux", repo: r.repo || null, harness: r.harness || null,
2289
2291
  model: r.model || null, parent: r.parentInstance || null,
@@ -2299,6 +2301,7 @@ async function spawnCmd() {
2299
2301
  }
2300
2302
  console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2301
2303
  console.log(` home: ${shortPath(r.home)}`);
2304
+ if (r.base) console.log(` base: ${r.base.oid.slice(0, 12)} (${r.base.ref === r.workspace?.soul?.repoKey ? `observed head of ${r.base.ref}` : r.base.ref})`);
2302
2305
  if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2303
2306
  if (wakeScheduleError) console.error(` wake: NOT saved — ${wakeScheduleError.message} (the instance is created and launched; add the wake by hand with oats schedule add)`);
2304
2307
  if (!r.launched) console.log(` launch: oats session start --home ${shellQuote(r.home)}`);
@@ -1394,13 +1394,13 @@ it to a temporary copy (`soulFetched: true`).
1394
1394
  "settingsOrigins":{"nw-tools":{},"oats.okf":{"/owns":{"kind":"soul","at":"soul.yaml#/knowledge"}}},
1395
1395
  "spawnPreviewApi":2,"preview":true,"agent":"rm","kind":"persistent","instance":"rm-api","home":"/w/agents/rm/instances/rm-api",
1396
1396
  "repo":"/w/agents-repo","work":"worktree","subject":{"soul":"rm","agentsRoot":null,"dir":"/w"},
1397
- "decision":{"instance":"rm-api","home":"/w/agents/rm/instances/rm-api","branch":"agents/rm-api","base":{"ref":"HEAD","oid":"66566512…"},
1397
+ "decision":{"instance":"rm-api","home":"/w/agents/rm/instances/rm-api","branch":"agents/rm-api","base":{"ref":"github.com/nw/agents","oid":"66566512…"},
1398
1398
  "effective":{"repo":"/w/agents-repo","work":"worktree","harness":"pi","model":null,"launchConfig":null,"yolo":null,"backend":"tmux",
1399
1399
  "childSpawns":true,"relation":null,"providers":{"nw-tools":{},"oats.okf":{"owns":"rm"}}},
1400
1400
  "resolution":"abacbdb5a7975098d77007c8","revision":"c557d8ec9a272ba1c1739dc3"},
1401
1401
  "preflight":{"status":"complete","budgetMs":20000,"elapsedMs":53},"backendStatus":{"name":"tmux","installed":true,"started":false},
1402
1402
  "harness":"pi","model":null,"modelSource":"native default","launchConfig":null,"backend":"tmux",
1403
- "branch":"agents/rm-api","base":{"ref":"HEAD","oid":"66566512…"},"worktree":"/w/agents/rm/instances/rm-api/work",
1403
+ "branch":"agents/rm-api","base":{"ref":"github.com/nw/agents","oid":"66566512…"},"worktree":"/w/agents/rm/instances/rm-api/work",
1404
1404
  "relation":null,"parentInstance":null,"policy":{"childSpawns":{"allowed":true,"origin":{"kind":"default","detail":"no spawn option: children allowed"}}},
1405
1405
  "executable":"/usr/local/bin/pi",
1406
1406
  "capabilities":[{"name":"nw-tools","origin":"member:github.com/nw/agents@66566512…"},{"name":"oats.okf","origin":"package:oats.okf@2.1.3"}],
@@ -1415,8 +1415,11 @@ it to a temporary copy (`soulFetched: true`).
1415
1415
  else `null`) are canonical: never derive paths.
1416
1416
  - `repo`: `--repo`, else the `clones:` entry, else `<deployment>/<member>`.
1417
1417
  - `branch` defaults to `agents/<instance>` (`--branch` overrides); `base` is
1418
- `--base` (default `HEAD`) resolved to `oid`. `E_BRANCH_EXISTS` and
1419
- `E_BASE_UNKNOWN` refuse preview and apply alike.
1418
+ `--base` resolved to `oid`. Without `--base`, when `repo` is a clone of the
1419
+ soul's repository, `base` is `{ref: <repo key>, oid: <the commit the spawn
1420
+ observed>}`, fetched into the clone at apply (`E_REMOTE_UNREADABLE` when it
1421
+ cannot be); otherwise `HEAD`. `E_BRANCH_EXISTS` and `E_BASE_UNKNOWN` refuse
1422
+ preview and apply alike.
1420
1423
  - `subject` echoes `{soul, agentsRoot, dir}` byte-exact.
1421
1424
 
1422
1425
  **Launch.**
@@ -1534,7 +1537,8 @@ with `--expect-decision` records the key and decision in `instance.json`.
1534
1537
  **Result** (`oats spawn <soul> … --json`):
1535
1538
 
1536
1539
  ```json
1537
- {"instance":"rm-api","agent":"rm","home":"/w/agents/rm/instances/rm-api","work":"worktree","branch":"agents/rm-api","launched":true,"warnings":[],
1540
+ {"instance":"rm-api","agent":"rm","home":"/w/agents/rm/instances/rm-api","work":"worktree","branch":"agents/rm-api",
1541
+ "base":{"ref":"github.com/nw/agents","oid":"66566512…"},"launched":true,"warnings":[],
1538
1542
  "tmux":{"session":"oats-agents","window":"rm-api"},"backend":"tmux","repo":"/w/agents-repo","harness":"pi","model":null,"parent":null,"sibling":null,"relation":null,
1539
1543
  "spawnOrigin":"operator","attach":"tmux attach -t oats-agents","decision":{"instance":"rm-api","revision":"c557d8ec9a272ba1c1739dc3"},"replayed":false,
1540
1544
  "wake":{"requested":false,"saved":null,"error":null},"launchConfig":null,
@@ -1544,7 +1548,8 @@ with `--expect-decision` records the key and decision in `instance.json`.
1544
1548
 
1545
1549
  (`decision` is abridged: it is the full bound decision.)
1546
1550
 
1547
- - Always present: `instance, agent, home, work, branch, launched, warnings
1551
+ - Always present: `instance, agent, home, work, branch, base ({ref, oid}
1552
+ the new branch started at; `null` without one), launched, warnings
1548
1553
  (array), tmux ({session, window} | null), backend ("tmux"), repo, harness,
1549
1554
  model, parent,
1550
1555
  sibling, relation, spawnOrigin (operator | instance), attach, launchConfig,
@@ -1608,7 +1613,8 @@ workspace-model fields (feature `instance-modules`):
1608
1613
 
1609
1614
  ```json
1610
1615
  {"agent":"rm","kind":"persistent","instance":"rm-api","home":"/w/agents/rm/instances/rm-api","soulDir":"/w/agents/rm/souls/66566512168e",
1611
- "repo":"/w/agents-repo","work":"worktree","branch":"agents/rm-api","harness":"pi","modelFrom":"harness-default","spawnOrigin":"operator",
1616
+ "repo":"/w/agents-repo","work":"worktree","branch":"agents/rm-api","base":{"ref":"github.com/nw/agents","oid":"66566512…"},
1617
+ "harness":"pi","modelFrom":"harness-default","spawnOrigin":"operator",
1612
1618
  "policy":{"childSpawns":{"allowed":true,"origin":{"kind":"default","detail":"no spawn option: children allowed"}}},
1613
1619
  "modules":{"oats.okf":{"from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1614
1620
  "commit":"ab897841…","digest":"sha256-9a0e…","materializedAt":"2026-09-28T10:08:01.100Z"}},
@@ -1635,6 +1641,8 @@ the keyed-spawn fields `decision`, `spawnIdempotencyKey`, `spawnCompleted` and
1635
1641
  the copy at `<home>/.oats/modules/<cap>/`. Module skills are copied flat to
1636
1642
  `<home>/.agents/skills/<skill>/` (homes spawned by 0.30.1 or earlier keep
1637
1643
  `<home>/.agents/skills/<cap>/<skill>/`).
1644
+ - `base` (a worktree instance): `{ref, oid}`, the commit its branch started
1645
+ at, as the spawn result states it (see Placement under the spawn preview).
1638
1646
  - `providers.<cap>`: the merged payload (`{}` when none).
1639
1647
  - `workspace`: `{key, name, deployment, commit, resolution, standalone, soul,
1640
1648
  layers}`. `name` is recorded, and every hook, command and operation of the
@@ -2023,8 +2031,15 @@ A first retire prints the **raw receipt**, not an envelope:
2023
2031
 
2024
2032
  - `retention`: `{worktree: "retained" | "removed" | "absent", movedTo?,
2025
2033
  branch, detachedAt?, recordedBranch, branchDeleted?,
2026
- branchDeletionSkipped?: {expected, actual, reason}}`, or `null` for a
2027
- non-worktree mode.
2034
+ branchDeletionSkipped?: {expected, actual, reason}}`, or `null` when no
2035
+ worktree step ran: a non-worktree mode, or a worktree kept for the retry.
2036
+ A retire whose hooks left cleanup outstanding keeps the worktree exactly as
2037
+ it was (with `worktreeRemoved: false`) and says why in `rollbackIncomplete`
2038
+ (`git worktree <path>: kept for the retry; outstanding: …`); the retry does
2039
+ the step once nothing else is outstanding. A work directory whose git admin
2040
+ entry is gone is never touched: it is an incomplete item (`git worktree
2041
+ <path>: its admin entry is missing; …`), and `--force` refuses it with
2042
+ `E_WORK_PRESERVATION_FAILED`.
2028
2043
  - `--discard-worktree` removes the worktree. `--delete-branch` deletes the
2029
2044
  worktree's verified branch (re-verified at deletion time) and implies
2030
2045
  discarding; a mismatch deletes nothing and reports
@@ -0,0 +1,51 @@
1
+ # OATS 0.34.4
2
+
3
+ ## Fixed
4
+
5
+ - **A worktree spawn branches from the member commit it observed, not from
6
+ the clone's stale local branch** (awebai/oats#445). When a member clone
7
+ (`clones:`, the convention path or `--repo`) was a human's checkout or a
8
+ shared reference, `oats spawn` cut the instance branch from whatever that
9
+ clone had checked out, often far behind the head the spawn had just
10
+ resolved the soul at, and nobody was told. Now:
11
+ - the branch starts at the commit the spawn observed for its decision. The
12
+ spawn fetches that commit by id into the clone, from the remote that names
13
+ the soul's repository;
14
+ - the clone's own branches, remote-tracking refs and work tree are never
15
+ moved;
16
+ - a commit that cannot be fetched refuses the spawn with
17
+ `E_REMOTE_UNREADABLE`, naming the clone and the commit. Nothing is created
18
+ and nothing falls back to the clone's branch;
19
+ - the spawn result states the start point: `base: <oid> (observed head of
20
+ <repo>)` in text and `base: {ref, oid}` in `--json`. `instance.json`
21
+ records it too.
22
+
23
+ `--base <ref>` still names an explicit start point in the clone, and a
24
+ `--repo` that is not a clone of the soul's repository still starts at its
25
+ `HEAD`.
26
+
27
+ - **A retire whose hook reports incomplete cleanup leaves the worktree as it
28
+ was** (awebai/oats#444). The worktree used to be moved or removed before
29
+ the home was quarantined, so its git admin entry could be gone by the time
30
+ the retry needed it. The retry's work inspection then failed, and the hook
31
+ that asked to be retried could never run again. Now:
32
+ - the home is quarantined before any worktree step: the worktree, its admin
33
+ entry and the branch stay exactly as they were. `retire --json` reports
34
+ `retention: null` and `worktreeRemoved: false`, and `rollbackIncomplete`
35
+ says `git worktree <path>: kept for the retry; outstanding: …`;
36
+ - the retry does the worktree step only once nothing else is outstanding:
37
+ retain by default, remove with `--discard-worktree` or `--delete-branch`.
38
+ Only a failed spawn's quarantine that owes the worktree removes it;
39
+ - `--force` removes the home regardless, so it does the worktree step first
40
+ and leaves no admin entry pointing into a removed home;
41
+ - a work directory whose admin entry is gone is reported as incomplete, the
42
+ hooks still run, and the directory is never removed. `--force` refuses it
43
+ (`E_WORK_PRESERVATION_FAILED`) until you move it out or delete it by hand.
44
+ Upgrade before forcing: an older kernel's `--force` still removes such
45
+ a directory along with the home.
46
+
47
+ - **The spawn's fetch of its base reads remote names safely** (follow-up to
48
+ #445): the remote is passed after `--end-of-options`, so a remote whose
49
+ name reads like an option is never taken as one, and remote urls are read
50
+ NUL-separated (`git config -z`), so a newline in one url cannot forge
51
+ another remote.
@@ -432,6 +432,16 @@ retained home (plus the usual quarantine marker when hooks reported incomplete
432
432
  cleanup), shows in `oats status` and the Desktop as a failed deferred
433
433
  retirement, and is retried and cleared with `oats retire <instance>`.
434
434
 
435
+ When a retire hook reports incomplete cleanup, the home is quarantined before
436
+ any worktree step: the worktree, its git admin entry and the branch stay
437
+ exactly as they were, so the retry can reach the hook and the work it needs.
438
+ The retry does the worktree step only once nothing else is outstanding:
439
+ retain by default, remove with `--discard-worktree` or `--delete-branch`.
440
+ `--force` removes the home regardless, so it does the worktree step first. A
441
+ work directory whose git admin entry is gone is never removed: the hooks still
442
+ run, the home is kept, and `--force` refuses it until you move the directory
443
+ out or delete it by hand.
444
+
435
445
  Retire never deletes a branch unless you pass `--delete-branch`, and then
436
446
  only the verified branch: not on a quarantine, its retry or `--force`. A
437
447
  spawn that fails deletes the branch it created only while the branch's tip
@@ -472,6 +482,21 @@ as `--repo`, then `oats-local.yaml` `clones:`, then `<deployment>/<member name>`
472
482
  (`E_CLONE_MISSING` / `E_CLONE_MISMATCH` otherwise — see
473
483
  [configuration.md](configuration.md)).
474
484
 
485
+ The branch starts at the commit of the soul's repository that the spawn
486
+ observed (the one it resolved the soul at), not at what the clone has checked
487
+ out: the clone may be a human's checkout or a shared reference, far behind.
488
+ The spawn fetches that commit by id into the clone from the remote that names
489
+ the repository, and never moves the clone's own branches, remote-tracking refs
490
+ or work tree. A commit that cannot be fetched refuses the spawn
491
+ (`E_REMOTE_UNREADABLE`, naming the clone and the commit); it never falls back
492
+ to the clone's branch. The fetch never prompts (ssh runs in BatchMode, askpass
493
+ is refused). A server that serves only its advertised refs (protocol v0 without
494
+ `allowAnySHA1InWant`) refuses a commit its branches have moved past; `--base`
495
+ is then the way on. `--base <ref>` names another start point in the clone;
496
+ a `--repo` that is not a clone of the soul's repository starts at its `HEAD`.
497
+ The spawn result and `instance.json` record the start point as
498
+ `base: {ref, oid}`.
499
+
475
500
  Use this for agents that will edit code or docs independently.
476
501
 
477
502
  Rules:
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.34.3",
3
+ "version": "0.34.4",
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",