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