@awebai/oats 0.34.2 → 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/capabilities.md +2 -1
- package/docs/desktop-cli-api.md +35 -16
- package/docs/integrations.md +2 -2
- package/docs/knowledge.md +1 -1
- package/docs/official-catalog.md +3 -3
- package/docs/packages.md +10 -10
- package/docs/release-notes/v0.34.3.md +48 -0
- package/docs/release-notes/v0.34.4.md +51 -0
- package/docs/souls-and-instances.md +33 -2
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +182 -72
- package/lib/instance-resolution.mjs +23 -9
- package/lib/remote.mjs +3 -1
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/README.md +21 -0
- package/packages/record/bin/capture.mjs +3 -0
- package/packages/record/lib/capture-lock.mjs +92 -25
- package/skills/oats-getting-started/SKILL.md +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/capabilities.md
CHANGED
|
@@ -104,7 +104,8 @@ A self-contained package has an `oats.json`:
|
|
|
104
104
|
permanent external residue. It is marked `.oats-rollback-incomplete.json`, so
|
|
105
105
|
`oats status` reports it as retained state rather than a live instance, and
|
|
106
106
|
`oats retire <instance>` retries the cleanup — re-running the retire hooks and
|
|
107
|
-
the
|
|
107
|
+
the worktree removal, verifying both, and verifying (never deleting) the
|
|
108
|
+
branch: a branch is deleted only with `--delete-branch`. A retry that still cannot
|
|
108
109
|
finish keeps the home again, names what is outstanding, and exits nonzero.
|
|
109
110
|
- The **escape hatch is `oats retire <instance> --force`**, for a home OATS cannot
|
|
110
111
|
identify at all: no `instance.json` and no **usable** cleanup descriptor. Usable
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -723,7 +723,7 @@ Read-only (it writes no lock):
|
|
|
723
723
|
"souls":["rm"],"capabilities":["nw-house-style"],"publishes":null,"url":"https://github.com/nw/agents/tree/66566512…",
|
|
724
724
|
"membershipFile":{"path":"oats-membership.yaml","url":"https://github.com/nw/agents/blob/66566512…/oats-membership.yaml"}}],
|
|
725
725
|
"packages":[{"id":"oats.okf","version":"3.0.0","source":"catalog:oats.okf","commit":"ab897841…","integrity":"sha256-bada35…",
|
|
726
|
-
"capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0.
|
|
726
|
+
"capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0.7","ref":"v4.0.7"}}],
|
|
727
727
|
"declaredPackages":["oats.framework","oats.okf"],"unsynced":["oats.framework"],"stale":[],
|
|
728
728
|
"external":[{"source":"git:github.com/oss/experts@3c606e09…","soul":"security-reviewer"}],
|
|
729
729
|
"problems":[],"warnings":[],
|
|
@@ -782,8 +782,8 @@ packages' capabilities and souls, sorted by name, then origin. Both carry
|
|
|
782
782
|
"defaultTeam":{"label":"mine","team":"mine:ana.aweb.ai","from":"deployment"},
|
|
783
783
|
"private":false,"path":"souls/writer","work":"directory","description":"Drafts campaigns.","harness":"pi","model":null,"harnessFrom":"kernel-default",
|
|
784
784
|
"file":{"path":"souls/writer/soul.yaml","url":null},"spawnable":true,"problem":null},
|
|
785
|
-
{"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0.
|
|
786
|
-
"version":"4.0.
|
|
785
|
+
{"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0.7","kind":"package","package":"oats.okf",
|
|
786
|
+
"version":"4.0.7","repoKey":"github.com/awebai/oats-okf","commit":"e460b29a…","teams":null,"defaultTeam":null,"private":false,
|
|
787
787
|
"path":"oats-package/souls/knowledge-maintainer","work":"directory","description":"Reviews harvested knowledge.","harness":"pi","model":null,
|
|
788
788
|
"harnessFrom":"kernel-default","file":{"path":"oats-package/souls/knowledge-maintainer/soul.yaml","url":null},
|
|
789
789
|
"spawnable":false,"problem":{"code":"E_TEAM_UNKNOWN","message":"team \"reviewers\" is not declared (oats-local.yaml#/souls/teams/…)"}}],
|
|
@@ -872,8 +872,8 @@ nothing reads a working clone.
|
|
|
872
872
|
**The show:**
|
|
873
873
|
|
|
874
874
|
```json
|
|
875
|
-
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.
|
|
876
|
-
"commit":"
|
|
875
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.7",
|
|
876
|
+
"commit":"e460b29a…","path":"oats-package/capabilities/oats-okf",
|
|
877
877
|
"inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
|
|
878
878
|
"skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
|
|
879
879
|
"files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
|
|
@@ -899,7 +899,7 @@ nothing reads a working clone.
|
|
|
899
899
|
**The `--file` answer:**
|
|
900
900
|
|
|
901
901
|
```json
|
|
902
|
-
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"
|
|
902
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"e460b29a…",
|
|
903
903
|
"file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
|
|
904
904
|
```
|
|
905
905
|
|
|
@@ -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,12 +2031,23 @@ 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
|
|
2031
|
-
`branchDeletionSkipped`.
|
|
2046
|
+
`branchDeletionSkipped`. Without `--delete-branch` no retire deletes a
|
|
2047
|
+
branch, a retried or `--force`d quarantine included. A failed spawn's
|
|
2048
|
+
quarantine that still owes the branch the spawn created stays incomplete
|
|
2049
|
+
(`git branch <b>: kept; the failed spawn created it; pass --delete-branch to
|
|
2050
|
+
delete it`).
|
|
2032
2051
|
- `workRecovery` (or `workRecoveries[]`): `{path, classes, bytes, outputs?,
|
|
2033
2052
|
repoCopy?}`; `outputs: {paths: [{path, bytes}], bytes}` names what was
|
|
2034
2053
|
copied beyond tracked state, largest first.
|
package/docs/integrations.md
CHANGED
package/docs/knowledge.md
CHANGED
package/docs/official-catalog.md
CHANGED
|
@@ -10,8 +10,8 @@ or workspace membership alone does not make a package official.
|
|
|
10
10
|
| package | release | capabilities | package souls |
|
|
11
11
|
|---|---|---|---|
|
|
12
12
|
| `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
|
|
13
|
-
| `oats.okf` | `v4.0.
|
|
14
|
-
| `oats.aweb` | `v1.17.
|
|
13
|
+
| `oats.okf` | `v4.0.7` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
|
|
14
|
+
| `oats.aweb` | `v1.17.7` | `oats.aweb` (messaging) | |
|
|
15
15
|
| `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
|
|
16
16
|
| `oats.authoring` | `v1.0.3` | `oats.authoring` | |
|
|
17
17
|
| `oats.jira` | `v1.0.1` | `oats.jira` (tasks) | |
|
|
@@ -27,7 +27,7 @@ no lock and adds nothing to an existing workspace.
|
|
|
27
27
|
## Find and use packages
|
|
28
28
|
|
|
29
29
|
- A workspace pins an official package by **bare version** in its
|
|
30
|
-
`packages:` map (`oats.okf: v4.0.
|
|
30
|
+
`packages:` map (`oats.okf: v4.0.7`); `oats sync` resolves it through the
|
|
31
31
|
catalog to an exact commit, fetches it, verifies its integrity and locks it.
|
|
32
32
|
A package outside the catalog is written `git:<repo>@<ref>`. Pinning does
|
|
33
33
|
not join a team or adopt the publisher's workspace. See
|
package/docs/packages.md
CHANGED
|
@@ -44,14 +44,14 @@ whole organisation:
|
|
|
44
44
|
|
|
45
45
|
```yaml
|
|
46
46
|
packages:
|
|
47
|
-
oats.okf: v4.0.
|
|
47
|
+
oats.okf: v4.0.7 # bare version → the official catalog
|
|
48
48
|
acme.tools: git:github.com/acme/tools@v0.4.0 # direct ref: git:<repo>@<tag or full OID>
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
- **Bare version** (`v4.0.
|
|
51
|
+
- **Bare version** (`v4.0.7`, `4.0.7`, `1.0.0-rc.1`): the id is looked up in
|
|
52
52
|
the official catalog — `package-catalog.json` in the `oats` repo, or the file
|
|
53
53
|
named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
|
|
54
|
-
convention (`v4.0.
|
|
54
|
+
convention (`v4.0.7` or `oats-framework/v1.4.1`) and the payload path. An id
|
|
55
55
|
the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
|
|
56
56
|
a package outside the catalog"). The catalog is the reviewed official list
|
|
57
57
|
([official-catalog.md](official-catalog.md)) and the only way a
|
|
@@ -75,8 +75,8 @@ members:
|
|
|
75
75
|
- git:github.com/acme/platform
|
|
76
76
|
packages:
|
|
77
77
|
oats.framework: v1.4.1
|
|
78
|
-
oats.okf: v4.0.
|
|
79
|
-
oats.aweb: v1.17.
|
|
78
|
+
oats.okf: v4.0.7
|
|
79
|
+
oats.aweb: v1.17.7
|
|
80
80
|
teams:
|
|
81
81
|
platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
|
|
82
82
|
defaults:
|
|
@@ -105,7 +105,7 @@ decision recorded in the lock.
|
|
|
105
105
|
$ oats sync
|
|
106
106
|
workspace acme (github.com/acme/agents @ 3f2a9c1e)
|
|
107
107
|
members agents ✓↔ (@ 3f2a9c1e) platform ✓↔ (@ 77c0a1b2) billing ✗ (no-backlink)
|
|
108
|
-
packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0.
|
|
108
|
+
packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0.7 ✓ (@ e460b29a)
|
|
109
109
|
changed acme.tools — → 0.4.0 (@ 47f4b816)
|
|
110
110
|
souls 9 discovered (6 members, 1 external, 2 package, 0 disabled here) · 0 private capabilities
|
|
111
111
|
teams platform (shared) · this deployment's: oats teams
|
|
@@ -134,7 +134,7 @@ Declaring a package in the workspace's `packages:` is the trust decision
|
|
|
134
134
|
## `oats package add | remove`
|
|
135
135
|
|
|
136
136
|
```bash
|
|
137
|
-
oats package add oats.aweb v1.17.
|
|
137
|
+
oats package add oats.aweb v1.17.7 # a catalog version
|
|
138
138
|
oats package add acme.tools git:github.com/acme/tools@v0.4.0
|
|
139
139
|
oats package remove acme.tools
|
|
140
140
|
```
|
|
@@ -160,8 +160,8 @@ same workspace commit hold identical locks.
|
|
|
160
160
|
"source": "catalog:oats.okf",
|
|
161
161
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
162
162
|
"path": "oats-package",
|
|
163
|
-
"version": "4.0.
|
|
164
|
-
"commit": "
|
|
163
|
+
"version": "4.0.7",
|
|
164
|
+
"commit": "e460b29aaf23db5728d7c64f7b5fb63f5014546b",
|
|
165
165
|
"integrity": "sha256-…",
|
|
166
166
|
"capabilities": ["oats.okf", "oats.okf-harvest", "oats.okf-maintenance"]
|
|
167
167
|
},
|
|
@@ -331,7 +331,7 @@ A soul that names one of the package's capabilities with
|
|
|
331
331
|
{
|
|
332
332
|
"policy": "docs/official-catalog.md",
|
|
333
333
|
"packages": {
|
|
334
|
-
"oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.
|
|
334
|
+
"oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.7", "path": "oats-package" },
|
|
335
335
|
"oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.1", "path": "oats-package" }
|
|
336
336
|
}
|
|
337
337
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# OATS 0.34.3
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **oats.okf 4.0.7** (catalog and workspace pin, and the bundled mirrors):
|
|
6
|
+
`oats okf complete` records acceptance of an amended and merged harvest PR
|
|
7
|
+
(awebai/oats-okf#32). After the knowledge-maintainer's `amend+merge`, the
|
|
8
|
+
after-merge `complete --run <id>` failed with `E_BASELINE`, and the receipt
|
|
9
|
+
stayed `delivered`. Now:
|
|
10
|
+
- a merged PR is settled by its merge before any baseline check, and the
|
|
11
|
+
receipt records `mergeCommit`;
|
|
12
|
+
- when the PR was merged at a head the maintainer amended, an `okf-review`
|
|
13
|
+
verdict (`merge` or `amend+merge`) must name that head and the PR URL. It
|
|
14
|
+
must come from a repository member, or from the account that merged the
|
|
15
|
+
PR (which covers a maintainer on a GitHub App token);
|
|
16
|
+
- without such a verdict, `complete` fails with `E_PR` and says what to do.
|
|
17
|
+
Merged inputs are never rejudged.
|
|
18
|
+
|
|
19
|
+
- **oats.aweb 1.17.7** (catalog and workspace pin, and the bundled mirror):
|
|
20
|
+
native retire retries are idempotent after a successful default-workspace
|
|
21
|
+
self-delete. The provider records a local completion marker, so if another
|
|
22
|
+
retire hook keeps the home and the kernel retries, oats.aweb does not call
|
|
23
|
+
`aw workspace delete` again with the already-revoked certificate.
|
|
24
|
+
|
|
25
|
+
## Fixed
|
|
26
|
+
|
|
27
|
+
- **Retire no longer deletes a branch you did not ask it to delete**
|
|
28
|
+
(awebai/oats#436, data safety). When a retire hook reported incomplete
|
|
29
|
+
cleanup, the home was quarantined, and the retry (or `oats retire --force`)
|
|
30
|
+
ran `git branch -D` on the instance's branch even without
|
|
31
|
+
`--delete-branch`, taking any unpushed commits with it. Now:
|
|
32
|
+
- only `--delete-branch` deletes a branch, the verified one;
|
|
33
|
+
- a quarantine and its retry never do;
|
|
34
|
+
- a failed spawn deletes its own new branch only while the branch's tip is
|
|
35
|
+
still where the spawn created it (`git update-ref -d` with that commit),
|
|
36
|
+
and otherwise keeps it and says so.
|
|
37
|
+
|
|
38
|
+
- **A capture that is killed no longer blocks every later capture**
|
|
39
|
+
(awebai/oats#437). A capture pass killed mid-pass (a hook timeout, or
|
|
40
|
+
okf's 60 s bound) left the record root's capture lock behind, and every
|
|
41
|
+
later `oats capture` skipped. Retires that needed a final capture then
|
|
42
|
+
failed. Now:
|
|
43
|
+
- the next pass reclaims a lock whose recorded owner is dead on this host,
|
|
44
|
+
under a guard, and says so on stderr;
|
|
45
|
+
- a live or unknown owner, an initializing lock and another host's lock
|
|
46
|
+
are never touched;
|
|
47
|
+
- locks written by earlier kernels, which do not record the host, count as
|
|
48
|
+
this host's, so a stale lock already on disk clears on the next pass.
|
|
@@ -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.
|
|
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
|
|
|
152
152
|
"commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
|
|
153
153
|
},
|
|
154
154
|
"oats.okf": {
|
|
155
|
-
"from": { "kind": "package", "package": "oats.okf", "version": "4.0.
|
|
156
|
-
"commit": "
|
|
155
|
+
"from": { "kind": "package", "package": "oats.okf", "version": "4.0.7", "commit": "e460b29a…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
|
|
156
|
+
"commit": "e460b29a…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"providers": {
|
|
@@ -432,6 +432,22 @@ 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
|
+
|
|
445
|
+
Retire never deletes a branch unless you pass `--delete-branch`, and then
|
|
446
|
+
only the verified branch: not on a quarantine, its retry or `--force`. A
|
|
447
|
+
spawn that fails deletes the branch it created only while the branch's tip
|
|
448
|
+
is still where the spawn created it. If something was committed there, the
|
|
449
|
+
branch is kept and the failure says so.
|
|
450
|
+
|
|
435
451
|
## Work modes
|
|
436
452
|
|
|
437
453
|
A work mode decides what `./work` points at and what discipline the agent must
|
|
@@ -466,6 +482,21 @@ as `--repo`, then `oats-local.yaml` `clones:`, then `<deployment>/<member name>`
|
|
|
466
482
|
(`E_CLONE_MISSING` / `E_CLONE_MISMATCH` otherwise — see
|
|
467
483
|
[configuration.md](configuration.md)).
|
|
468
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
|
+
|
|
469
500
|
Use this for agents that will edit code or docs independently.
|
|
470
501
|
|
|
471
502
|
Rules:
|
package/docs/workspaces.md
CHANGED
|
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
57
|
oats.framework: v1.4.1 # bare version → resolves through the official catalog
|
|
58
|
-
oats.okf: v4.0.
|
|
58
|
+
oats.okf: v4.0.7
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
|
61
61
|
teams: # SHARED teams: the same provider team for everyone
|
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.
|
|
@@ -3395,11 +3406,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3395
3406
|
const list = run(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
|
|
3396
3407
|
if (!list.ok) incomplete.push(`git worktree ${wt}: could not verify removal (${list.err || "worktree list failed"})`);
|
|
3397
3408
|
else incomplete.push(`git worktree ${wt}: could not verify removal (canonical path unavailable after add)`);
|
|
3398
|
-
|
|
3399
|
-
if (!del.ok) incomplete.push(`git branch ${branch}: deletion failed (${del.err || `exit ${del.status}`})`);
|
|
3400
|
-
const ref = run(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
3401
|
-
if (ref.ok) incomplete.push(`git branch ${branch}: still exists`);
|
|
3402
|
-
else if (ref.status !== 1 || ref.err) incomplete.push(`git branch ${branch}: could not verify deletion (${ref.err || `exit ${ref.status}`})`);
|
|
3409
|
+
incomplete.push(...deleteBranchAsCreated(run, repoAbs, branch, plannedBase.oid));
|
|
3403
3410
|
}
|
|
3404
3411
|
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
3405
3412
|
const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
|
|
@@ -3576,10 +3583,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3576
3583
|
if (worktreeCanonical && registered.includes(worktreeCanonical)) { incomplete.push(`git worktree ${worktreeCanonical}: still registered`); outstandingGit.add("worktree"); }
|
|
3577
3584
|
}
|
|
3578
3585
|
if (branch) {
|
|
3579
|
-
probe
|
|
3580
|
-
|
|
3581
|
-
if (brProbe.ok) { incomplete.push(`git branch ${branch}: still exists`); outstandingGit.add("branch"); }
|
|
3582
|
-
else if (brProbe.status !== 1 || brProbe.err) { incomplete.push(`git branch ${branch}: could not verify deletion (${brProbe.err || `rev-parse exit ${brProbe.status}`})`); outstandingGit.add("branch"); }
|
|
3586
|
+
const branchDebt = deleteBranchAsCreated(probe, repoAbs, branch, plannedBase.oid);
|
|
3587
|
+
if (branchDebt.length) { incomplete.push(...branchDebt); outstandingGit.add("branch"); }
|
|
3583
3588
|
}
|
|
3584
3589
|
}
|
|
3585
3590
|
// Any attempted spawn hook may have created state before a later hook (or
|
|
@@ -3674,7 +3679,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3674
3679
|
const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
|
|
3675
3680
|
const meta = {
|
|
3676
3681
|
agent: agent.name, kind: agent.kind || "persistent", instance, home, soulDir: homeSoulTarget,
|
|
3677
|
-
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,
|
|
3678
3683
|
// Feature launch-preference: the layer that decided this launch, and the soul's own preference then.
|
|
3679
3684
|
launchFrom: launchChoice.from, launchAt: launchChoice.at, launchDeclared: launchChoice.declared,
|
|
3680
3685
|
...(yolo !== undefined ? { yolo } : {}),
|
|
@@ -4114,9 +4119,50 @@ function sessionDirectoryGuard(home) {
|
|
|
4114
4119
|
return check;
|
|
4115
4120
|
}
|
|
4116
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
|
+
|
|
4152
|
+
/** A failed spawn's compare-and-delete of the branch it created at `oid`: `git update-ref -d` refuses
|
|
4153
|
+
* atomically if the tip moved (something was committed there), and the branch is then kept. `run`
|
|
4154
|
+
* answers { ok, out, status, err }. → what is still owed, as messages ([] when the branch is gone). */
|
|
4155
|
+
function deleteBranchAsCreated(run, repoAbs, branch, oid) {
|
|
4156
|
+
const del = run(["git", "-C", repoAbs, "update-ref", "-d", `refs/heads/${branch}`, oid]);
|
|
4157
|
+
const ref = run(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
4158
|
+
if (ref.ok && ref.out.trim() !== oid) return [`git branch ${branch}: kept; its tip moved from ${oid.slice(0, 12)}, where this spawn created it`];
|
|
4159
|
+
if (ref.ok) return [`git branch ${branch}: still exists${del.ok ? "" : ` (deletion failed: ${del.err || `exit ${del.status}`})`}`];
|
|
4160
|
+
if (ref.status !== 1 || ref.err) return [`git branch ${branch}: could not verify deletion (${ref.err || `exit ${ref.status}`})`];
|
|
4161
|
+
return [];
|
|
4162
|
+
}
|
|
4117
4163
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
4118
4164
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
4119
|
-
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) }) {
|
|
4120
4166
|
const marker = {
|
|
4121
4167
|
// `reason` is optional and DEFAULTS to the spawn wording, so every existing
|
|
4122
4168
|
// caller is byte-identical; only a caller that supplies one differs. The
|
|
@@ -4136,7 +4182,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
|
|
|
4136
4182
|
// instance.json may be only the pre-hook stub, which records neither.
|
|
4137
4183
|
...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
|
|
4138
4184
|
...(typeof soulId === "string" && soulId ? { soulId } : {}),
|
|
4139
|
-
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
|
|
4185
|
+
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}), ...(orphanedWork ? { orphanedWork: true } : {}) },
|
|
4140
4186
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
4141
4187
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
|
|
4142
4188
|
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
@@ -4207,7 +4253,11 @@ function usableCleanupDescriptor(marker) {
|
|
|
4207
4253
|
if (c.capabilityMeta !== undefined && !isPlainObject(c.capabilityMeta)) return false;
|
|
4208
4254
|
const directoryDebt = c.outstanding?.directory === true && c.work === "directory";
|
|
4209
4255
|
if (c.outstanding?.directory !== undefined && !directoryDebt) return false;
|
|
4210
|
-
|
|
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;
|
|
4211
4261
|
if (!c.capabilityRuntime.every((cap) => isPlainObject(cap) && nonEmptyString(cap.id))) return false;
|
|
4212
4262
|
if (!isPlainObject(c.outstanding) || !Array.isArray(c.outstanding.hooks) || !Array.isArray(c.outstanding.git)) return false;
|
|
4213
4263
|
if (!c.outstanding.hooks.every(nonEmptyString)) return false;
|
|
@@ -4219,7 +4269,7 @@ function usableCleanupDescriptor(marker) {
|
|
|
4219
4269
|
// obligation of zero. Directory preservation is also real debt: the retry's
|
|
4220
4270
|
// independent-authority inspection and verified snapshots must succeed even
|
|
4221
4271
|
// when no capability has a retire hook. It is never a Git/shared-work escape.
|
|
4222
|
-
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;
|
|
4223
4273
|
// The retry must be ABLE to rerun what it must prove: an outstanding hook whose
|
|
4224
4274
|
// capability is not in the set could never run, so the quarantine would never
|
|
4225
4275
|
// clear — and the home would be unremovable without --force.
|
|
@@ -4296,6 +4346,21 @@ function worktreeRef(work) {
|
|
|
4296
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; }
|
|
4297
4347
|
return { branch, oid };
|
|
4298
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
|
+
}
|
|
4299
4364
|
function worktreeStatus(repo) {
|
|
4300
4365
|
try {
|
|
4301
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 });
|
|
@@ -5167,7 +5232,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5167
5232
|
}
|
|
5168
5233
|
}
|
|
5169
5234
|
|
|
5170
|
-
function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false } = {}) {
|
|
5235
|
+
function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false, orphanedWork = false } = {}) {
|
|
5171
5236
|
if (directory) assertDirectoryRoots(home);
|
|
5172
5237
|
const classes = [];
|
|
5173
5238
|
let baseline;
|
|
@@ -5208,7 +5273,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5208
5273
|
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
5209
5274
|
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
5210
5275
|
.digest("hex");
|
|
5211
|
-
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 };
|
|
5212
5277
|
}
|
|
5213
5278
|
|
|
5214
5279
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -5413,9 +5478,11 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5413
5478
|
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
5414
5479
|
// In-progress Git operations retain the full standalone recovery even
|
|
5415
5480
|
// when porcelain status has no changed paths.
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
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) {
|
|
5419
5486
|
const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5420
5487
|
homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
5421
5488
|
}
|
|
@@ -5719,10 +5786,20 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5719
5786
|
if (self && (!o.keepDir || herdrHome)) {
|
|
5720
5787
|
return scheduleDeferredSelfRetirement(root, found, name, o, session);
|
|
5721
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;
|
|
5722
5798
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
5723
5799
|
// the managed harness; recovery copying never races a live managed Pi.
|
|
5800
|
+
const owesWorktree = !!quarantine && (quarantine.cleanup.outstanding?.git || []).includes("worktree");
|
|
5724
5801
|
const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
|
|
5725
|
-
const initialObservation = inspectRetirementWork(found.home, workPath,
|
|
5802
|
+
const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5726
5803
|
// Harness identity is destructive authority. The mutable child metadata may
|
|
5727
5804
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5728
5805
|
// endpoint that proves quiescence.
|
|
@@ -5768,7 +5845,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5768
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"}`);
|
|
5769
5846
|
}
|
|
5770
5847
|
}
|
|
5771
|
-
const stableObservation = inspectRetirementWork(found.home, workPath,
|
|
5848
|
+
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5772
5849
|
const workRecoveries = [];
|
|
5773
5850
|
if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
|
|
5774
5851
|
let workRecovery = workRecoveries.at(-1);
|
|
@@ -5816,11 +5893,38 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5816
5893
|
ordinaryIncomplete.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""} — external state may remain`);
|
|
5817
5894
|
}
|
|
5818
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);
|
|
5819
5923
|
}
|
|
5820
5924
|
|
|
5821
5925
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
5822
5926
|
// them and preserve a separately verified post-hook snapshot when needed.
|
|
5823
|
-
const finalObservation = inspectRetirementWork(found.home, workPath,
|
|
5927
|
+
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
|
|
5824
5928
|
if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
|
|
5825
5929
|
workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
|
|
5826
5930
|
workRecovery = workRecoveries.at(-1);
|
|
@@ -5885,14 +5989,23 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5885
5989
|
// recorded in the receipt. `discardWorktree` restores removal; branch
|
|
5886
5990
|
// deletion uses the VERIFIED current ref of the worktree, never the
|
|
5887
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;
|
|
5888
6001
|
let retention = null;
|
|
5889
|
-
if (
|
|
6002
|
+
if (worktreeStep && !worktreeDeferred) {
|
|
5890
6003
|
const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
|
|
5891
6004
|
const verifiedBranch = ref.branch;
|
|
5892
6005
|
// A branch cannot be deleted while a worktree has it checked out, so
|
|
5893
6006
|
// --delete-branch implies discarding the worktree (which is what every
|
|
5894
6007
|
// caller of it meant: clean up everything). Plain retire retains.
|
|
5895
|
-
if (o.discardWorktree || o.deleteBranch ||
|
|
6008
|
+
if (o.discardWorktree || o.deleteBranch || owesWorktree) {
|
|
5896
6009
|
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
5897
6010
|
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
5898
6011
|
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
@@ -5938,11 +6051,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5938
6051
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
5939
6052
|
// retry becomes the deletion the quarantine was preventing.
|
|
5940
6053
|
let stillIncomplete;
|
|
5941
|
-
let quarantineBranchDeleted = false;
|
|
5942
6054
|
// Ordinary path: quarantine instead of deleting, using the SAME writer the
|
|
5943
6055
|
// spawn rollback uses. Two copies of this logic is how a previous divergence
|
|
5944
6056
|
// happened (see quarantineInstanceHome), so there is still exactly one.
|
|
5945
6057
|
if (!quarantine && !self && ordinaryIncomplete.length) {
|
|
6058
|
+
if (keptForRetry) ordinaryIncomplete.push(keptForRetry);
|
|
5946
6059
|
const outstandingHooks = new Set();
|
|
5947
6060
|
for (const f of hookResults?.failures || []) outstandingHooks.add(f.capability);
|
|
5948
6061
|
for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
|
|
@@ -5956,64 +6069,61 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5956
6069
|
repoAbs: meta.repo, work: meta.work, branch: meta.branch,
|
|
5957
6070
|
resolvedCfg: { capabilities: meta.capabilityRuntime || [] },
|
|
5958
6071
|
hookMeta: meta.capabilityMeta || {}, compensationMeta: hookResults?.meta || {},
|
|
5959
|
-
|
|
6072
|
+
orphanedWork: !!orphanedWork,
|
|
6073
|
+
reason: outstandingHooks.size ? "retire hook reported incomplete cleanup" : "the work directory has no git admin entry",
|
|
5960
6074
|
});
|
|
5961
6075
|
stillIncomplete = ordinaryIncomplete;
|
|
5962
6076
|
}
|
|
5963
6077
|
if (quarantine) {
|
|
5964
|
-
const failures =
|
|
6078
|
+
const failures = [...retryFailures];
|
|
6079
|
+
if (keptForRetry) failures.push(keptForRetry);
|
|
5965
6080
|
// The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
|
|
5966
|
-
// redo those steps and verify them — not just rerun hooks.
|
|
5967
|
-
//
|
|
5968
|
-
// the normal-retire --delete-branch flag, and any failure keeps the home.
|
|
6081
|
+
// redo those steps and verify them — not just rerun hooks. A branch is never
|
|
6082
|
+
// deleted here: only --delete-branch deletes one (the verified branch, above).
|
|
5969
6083
|
if (meta.work === "worktree" && meta.repo) {
|
|
5970
6084
|
const gitProbe = (argv) => {
|
|
5971
6085
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
5972
6086
|
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
5973
6087
|
};
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
const
|
|
5981
|
-
if (
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
if (br.ok) failures.push(`git branch ${meta.branch}: still exists`);
|
|
5987
|
-
else if (br.status !== 1 || br.err) failures.push(`git branch ${meta.branch}: could not verify deletion (${br.err || `rev-parse exit ${br.status}`})`);
|
|
5988
|
-
// Verified gone: the result must say so, or --json misreports the very
|
|
5989
|
-
// cleanup this path just performed.
|
|
5990
|
-
else quarantineBranchDeleted = true;
|
|
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
|
+
}
|
|
5991
6100
|
}
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
6101
|
+
// The branch is a debt only when the failed spawn's rollback still owes its deletion, or when the
|
|
6102
|
+
// operator asked for it (--delete-branch); then it must be verified gone, and a branch kept is said.
|
|
6103
|
+
// --delete-branch: the branch the documented path deleted (the verified one) must be gone. A failed
|
|
6104
|
+
// spawn's quarantine that owes its recorded branch keeps it unless that branch was the one deleted.
|
|
6105
|
+
const verify = (branch) => {
|
|
6106
|
+
const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
6107
|
+
if (br.ok) return "exists";
|
|
6108
|
+
if (br.status !== 1 || br.err) { failures.push(`git branch ${branch}: could not verify whether it still exists (${br.err || `rev-parse exit ${br.status}`})`); return "unknown"; }
|
|
6109
|
+
return "gone";
|
|
6110
|
+
};
|
|
6111
|
+
const deleted = retention?.branchDeleted;
|
|
6112
|
+
if (deleted && verify(deleted) === "exists") failures.push(`git branch ${deleted}: still exists`);
|
|
6113
|
+
const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
|
|
6114
|
+
if (owesBranch && meta.branch && meta.branch !== deleted && verify(meta.branch) === "exists") {
|
|
6115
|
+
// Without a home left to retry from (--force), or when --delete-branch verified another branch, the
|
|
6116
|
+
// recorded branch is the operator's to delete by hand.
|
|
6117
|
+
failures.push(o.force || o.deleteBranch
|
|
6118
|
+
? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
|
|
6119
|
+
: `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
|
|
5996
6120
|
}
|
|
5997
6121
|
}
|
|
5998
|
-
// The decisive check: not "did anything fail" but "did the work that was
|
|
5999
|
-
// outstanding actually happen". A retry that resolves zero capabilities —
|
|
6000
|
-
// because the descriptor named none, or config drifted since the spawn —
|
|
6001
|
-
// otherwise reports a clean sweep it never performed, and the home and its
|
|
6002
|
-
// credential go with it (reviewer-dd03a98).
|
|
6003
|
-
const ran = new Set(hookResults?.order || []);
|
|
6004
6122
|
// Git debt is proven by the verification block above, which only runs for a
|
|
6005
6123
|
// worktree in a known repo. If it could not run, the debt stands.
|
|
6006
6124
|
if (quarantine.cleanup.outstanding?.git?.length && !(meta.work === "worktree" && meta.repo)) {
|
|
6007
6125
|
failures.push(`git ${quarantine.cleanup.outstanding.git.join(", ")}: not re-verified on this retry, so the cleanup they owed is unverified`);
|
|
6008
6126
|
}
|
|
6009
|
-
for (const capId of quarantine.cleanup.outstanding?.hooks || []) {
|
|
6010
|
-
if (ran.has(capId)) continue;
|
|
6011
|
-
const cap = (meta.capabilityRuntime || []).find((c) => c.id === capId);
|
|
6012
|
-
failures.push(cap && !cap.hooks?.retire
|
|
6013
|
-
? `${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\``
|
|
6014
|
-
: `retire hook ${capId}: did not run on this retry, so the cleanup it owed is unverified`);
|
|
6015
|
-
}
|
|
6016
|
-
if (!hookResults) failures.push("retire hooks could not be rerun (cleanup descriptor lost its context repo)");
|
|
6017
6127
|
if (failures.length) {
|
|
6018
6128
|
stillIncomplete = failures;
|
|
6019
6129
|
try {
|
|
@@ -6039,7 +6149,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6039
6149
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6040
6150
|
}
|
|
6041
6151
|
|
|
6042
|
-
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: (() => {
|
|
6043
6153
|
const w = [...(hookResults?.warnings || [])];
|
|
6044
6154
|
if (isCapturedHome(meta) && !quarantine) {
|
|
6045
6155
|
// A captured home retires through the workspace path; its captured retire hooks do not
|
|
@@ -339,22 +339,36 @@ function canonicalCloneKey(written) {
|
|
|
339
339
|
return s;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
/** The
|
|
343
|
-
* repo
|
|
344
|
-
function
|
|
342
|
+
/** The remotes a clone carries (any remote, not only origin), each with its url parsed
|
|
343
|
+
* to a repo key: [{ name, key }]. Not a git repo → null. */
|
|
344
|
+
function cloneRemotes(path) {
|
|
345
345
|
const git = (argv) => spawnSync("git", ["-C", path, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
346
346
|
const inside = git(["rev-parse", "--git-dir"]);
|
|
347
347
|
if (inside.status !== 0) return null;
|
|
348
|
-
|
|
349
|
-
const
|
|
348
|
+
// -z: each entry is `<key>\n<value>\0`, so a value holding a newline cannot read as another entry.
|
|
349
|
+
const cfg = git(["config", "-z", "--get-regexp", "^remote\\..*\\.url$"]);
|
|
350
|
+
const remotes = [];
|
|
350
351
|
if (cfg.status === 0) {
|
|
351
|
-
for (const
|
|
352
|
-
const
|
|
352
|
+
for (const entry of cfg.stdout.split("\0")) {
|
|
353
|
+
const nl = entry.indexOf("\n");
|
|
354
|
+
if (nl < 0) continue;
|
|
355
|
+
const key = entry.slice(0, nl), url = entry.slice(nl + 1).trim();
|
|
353
356
|
if (!url) continue;
|
|
354
|
-
|
|
357
|
+
const name = key.slice("remote.".length, -".url".length);
|
|
358
|
+
try { remotes.push({ name, key: parseRepoRef(url).key }); } catch { remotes.push({ name, key: `?/${url}` }); }
|
|
355
359
|
}
|
|
356
360
|
}
|
|
357
|
-
return
|
|
361
|
+
return remotes;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The remote urls a clone carries, parsed to their repo keys. Not a git repo → null. */
|
|
365
|
+
function cloneRemoteKeys(path) {
|
|
366
|
+
return cloneRemotes(path)?.map((r) => r.key) ?? null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The name of the clone's remote whose url names `key`, or null (no such remote, or not a git repo). */
|
|
370
|
+
export function cloneRemoteFor(path, key) {
|
|
371
|
+
return (cloneRemotes(path) || []).find((r) => sameRepoKey(r.key, key))?.name ?? null;
|
|
358
372
|
}
|
|
359
373
|
|
|
360
374
|
/** Two repo keys name the same repository. Hosted keys compare literally; `local/<abs>`
|
package/lib/remote.mjs
CHANGED
|
@@ -293,7 +293,9 @@ export function sshCommand() {
|
|
|
293
293
|
return sshCommandCache;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
/** The environment every git child of the kernel that may reach a remote runs under: it never prompts (no
|
|
297
|
+
* terminal prompt, askpass refused, ssh in BatchMode) and never fetches a missing object on its own. */
|
|
298
|
+
export function gitEnv() {
|
|
297
299
|
return {
|
|
298
300
|
...process.env,
|
|
299
301
|
GIT_TERMINAL_PROMPT: "0",
|
package/package-catalog.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"packages": {
|
|
4
4
|
"oats.okf": {
|
|
5
5
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
6
|
-
"ref": "v4.0.
|
|
6
|
+
"ref": "v4.0.7",
|
|
7
7
|
"path": "oats-package"
|
|
8
8
|
},
|
|
9
9
|
"oats.aweb": {
|
|
10
10
|
"url": "https://github.com/awebai/oats-aweb.git",
|
|
11
|
-
"ref": "v1.17.
|
|
11
|
+
"ref": "v1.17.7",
|
|
12
12
|
"path": "oats-package"
|
|
13
13
|
},
|
|
14
14
|
"oats.jira": {
|
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",
|
|
@@ -197,6 +197,27 @@ Journal writes fsync; note that on macOS `fsync(2)` does not guarantee media
|
|
|
197
197
|
durability (that would need `F_FULLFSYNC`, which Node's fs API does not
|
|
198
198
|
expose) — the guarantee is OS-crash-level, not power-loss-level.
|
|
199
199
|
|
|
200
|
+
**One capture pass at a time.** A pass takes the record root's
|
|
201
|
+
`.capture.lock` directory, whose `owner.json` records the pid, a nonce, the
|
|
202
|
+
start time and the host. A pass that finds the lock held skips (the next
|
|
203
|
+
pass catches up). A pass that is killed (a hook or caller timeout) runs no
|
|
204
|
+
cleanup, so the next pass reclaims a lock whose recorded owner is dead on
|
|
205
|
+
this host:
|
|
206
|
+
|
|
207
|
+
- Reclaimers are serialized by a guard, `.capture.lock.reclaim`, taken by
|
|
208
|
+
exclusive create. Under it the record is read again and removed only if it
|
|
209
|
+
still belongs to that dead owner.
|
|
210
|
+
- A live or unknowable owner, an owner-less (initializing) lock and another
|
|
211
|
+
host's lock are never touched.
|
|
212
|
+
- A guard left by a reclaimer that died is never removed. It is named, with
|
|
213
|
+
the exact recovery.
|
|
214
|
+
- Records that name no host predate host recording and live under this
|
|
215
|
+
user's home, so they count as this host's: a dead owner's lock is
|
|
216
|
+
reclaimed too. On a home shared across machines (NFS, or a synced
|
|
217
|
+
directory), such a record may belong to another host, whose pid means
|
|
218
|
+
nothing here; check that no capture runs on the other machines before the
|
|
219
|
+
first pass after upgrading.
|
|
220
|
+
|
|
200
221
|
## Upgrading
|
|
201
222
|
|
|
202
223
|
The derived index self-heals across schema changes by wiping and
|
|
@@ -183,6 +183,9 @@ function withCaptureLock(fn) {
|
|
|
183
183
|
let lock;
|
|
184
184
|
try {
|
|
185
185
|
lock = acquireCaptureLock(root);
|
|
186
|
+
// Said by the pass that removed it, never quiet, whether or not it then took the lock: a pass that
|
|
187
|
+
// died holding the lock is worth knowing about.
|
|
188
|
+
if (lock.reclaimed) console.error(`capture: reclaimed ${lock.path} from pid ${lock.reclaimed.pid}, which died (started ${lock.reclaimed.startedAt || "?"})`);
|
|
186
189
|
} catch (err) {
|
|
187
190
|
if (err.lockCleanup) {
|
|
188
191
|
const c = err.lockCleanup;
|
|
@@ -4,18 +4,33 @@
|
|
|
4
4
|
// and the next pass catches up, since reconciliation is idempotent.
|
|
5
5
|
//
|
|
6
6
|
// The lock is a DIRECTORY: mkdir is atomic and a directory is never
|
|
7
|
-
// observable half-created. The owner record (pid, start time)
|
|
8
|
-
// inside it after the mkdir.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
7
|
+
// observable half-created. The owner record (pid, nonce, start time, host)
|
|
8
|
+
// is written inside it after the mkdir. A live, unknowable or still
|
|
9
|
+
// initializing (owner-less) lock refuses the pass and names the holder and
|
|
10
|
+
// the operator recovery; it is never stolen.
|
|
11
|
+
//
|
|
12
|
+
// A lock whose recorded owner is DEAD on this host is reclaimed (a capture
|
|
13
|
+
// killed mid-pass by a hook or caller timeout runs no finally). Records
|
|
14
|
+
// without host predate host recording and live under this user's home, so
|
|
15
|
+
// they count as this host's (RECLAIM_HOSTLESS_RECORDS). Reclaimers
|
|
16
|
+
// are serialized by a guard, `<lock>.reclaim` (exclusive create): under it
|
|
17
|
+
// the owner record is read again and the lock removed only while it is still
|
|
18
|
+
// that dead owner's, so a reclaimer cannot remove a lock a live pass took
|
|
19
|
+
// meanwhile. A guard whose holder died is never removed (that would race
|
|
20
|
+
// exactly as removing the lock does); it is named with its recovery.
|
|
21
|
+
// Acquire never waits: it reclaims once or skips, and the next pass catches up.
|
|
22
|
+
// There is no signal handler: a pass is synchronous, so a JS handler would
|
|
23
|
+
// only run after the whole pass (turning a caller's timeout kill into a full
|
|
24
|
+
// pass), and SIGKILL cannot be handled; the reclaim is the recovery.
|
|
25
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
26
|
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { hostname } from "node:os";
|
|
17
28
|
import { join } from "node:path";
|
|
18
29
|
|
|
30
|
+
/** Whether an owner record that names no host is reclaimed when its pid is dead here: yes. Records without
|
|
31
|
+
* host predate host recording and live under this user's home, so they are this host's. */
|
|
32
|
+
export const RECLAIM_HOSTLESS_RECORDS = true;
|
|
33
|
+
|
|
19
34
|
export function captureLockPath(root) { return join(root, ".capture.lock"); }
|
|
20
35
|
|
|
21
36
|
/** "alive" | "dead" | "unknown" for an owner pid ("unknown" = exists but not signalable). */
|
|
@@ -24,8 +39,38 @@ export function holderLiveness(pid) {
|
|
|
24
39
|
try { process.kill(pid, 0); return "alive"; } catch (e) { return e.code === "EPERM" ? "unknown" : "dead"; }
|
|
25
40
|
}
|
|
26
41
|
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } };
|
|
43
|
+
const readOwner = (dir) => readJson(join(dir, "owner.json"));
|
|
44
|
+
|
|
45
|
+
/** Whether `owner` is a record of this host whose process is dead: the only lock this module reclaims. */
|
|
46
|
+
function deadHere(owner, { host, liveness, reclaimHostless }) {
|
|
47
|
+
if (!owner || !Number.isInteger(owner.pid)) return false;
|
|
48
|
+
const here = owner.host === undefined ? reclaimHostless : owner.host === host;
|
|
49
|
+
return here && liveness(owner.pid) === "dead";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Remove the lock `dir` held by the dead `owner`, serialized by the guard `<dir>.reclaim`. → { removed }
|
|
53
|
+
* (true only when THIS call removed it; otherwise it is left for the next pass), or { abandoned: { guard,
|
|
54
|
+
* pid } } when a reclaimer died holding the guard. */
|
|
55
|
+
function reclaimDeadLock(dir, owner, me, opts) {
|
|
56
|
+
const guard = `${dir}.reclaim`;
|
|
57
|
+
try { writeFileSync(guard, JSON.stringify(me), { flag: "wx", mode: 0o600 }); }
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (e.code !== "EEXIST") return { removed: false };
|
|
60
|
+
const g = readJson(guard);
|
|
61
|
+
return g && deadHere(g, opts) ? { abandoned: { guard, pid: g.pid } } : { removed: false };
|
|
62
|
+
}
|
|
63
|
+
const same = (o) => o && o.pid === owner.pid && o.nonce === owner.nonce && o.startedAt === owner.startedAt;
|
|
64
|
+
let removed = false;
|
|
65
|
+
try {
|
|
66
|
+
const now = readOwner(dir);
|
|
67
|
+
if (same(now) && deadHere(now, opts)) {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
removed = !existsSync(dir) || !same(readOwner(dir));
|
|
70
|
+
}
|
|
71
|
+
} catch { /* the next pass */ }
|
|
72
|
+
finally { if (readJson(guard)?.nonce === me.nonce) { try { unlinkSync(guard); } catch { /* gone */ } } }
|
|
73
|
+
return { removed };
|
|
29
74
|
}
|
|
30
75
|
|
|
31
76
|
/** Single-quote shell escaping: safe to paste whatever the path contains. */
|
|
@@ -40,9 +85,13 @@ export function recoveryInstruction(dir, owner, liveness) {
|
|
|
40
85
|
return `${dir} is held by ${who}; if that process is gone (ps -p ${owner.pid}), remove the lock with: ${remove} and rerun`;
|
|
41
86
|
}
|
|
42
87
|
|
|
43
|
-
/** Try to take the root's capture lock. Returns { path, release }
|
|
44
|
-
* taken, or { path, held: { pid, startedAt, liveness, recovery
|
|
45
|
-
*
|
|
88
|
+
/** Try to take the root's capture lock. Returns { path, release, reclaimed? }
|
|
89
|
+
* when taken, or { path, held: { pid, startedAt, liveness, recovery, guard? },
|
|
90
|
+
* reclaimed? } when a lock is held. A lock it did not create is removed only
|
|
91
|
+
* when its recorded owner is dead on this host, under the reclaim guard (the
|
|
92
|
+
* header); `reclaimed: { pid, startedAt }` says THIS call removed it (whether
|
|
93
|
+
* or not it then won the lock), and `held.guard` names a guard a dead
|
|
94
|
+
* reclaimer left. Every other lock is left alone.
|
|
46
95
|
*
|
|
47
96
|
* Two failure points are reported rather than left behind. If the owner
|
|
48
97
|
* record cannot be written after THIS call created the directory (a full
|
|
@@ -59,22 +108,39 @@ export function recoveryInstruction(dir, owner, liveness) {
|
|
|
59
108
|
* The owner record carries a per-acquisition nonce, so a release kept from
|
|
60
109
|
* an earlier acquisition cannot erase a later one by the same pid (an
|
|
61
110
|
* operator recovery followed by a new pass in the same long-lived process).
|
|
62
|
-
* That is ownership checking; no lock is ever reclaimed.
|
|
63
111
|
*
|
|
64
112
|
* `io` exists for fault injection in tests only. */
|
|
65
|
-
export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, io = {} } = {}) {
|
|
113
|
+
export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, host = hostname(), reclaimHostless = RECLAIM_HOSTLESS_RECORDS, io = {} } = {}) {
|
|
66
114
|
const fs = { writeFileSync, rmSync, openSync, closeSync, lstatSync, ...io };
|
|
67
115
|
const dir = captureLockPath(root);
|
|
116
|
+
const nonce = randomBytes(8).toString("hex");
|
|
68
117
|
mkdirSync(root, { recursive: true }); // the store creates the root lazily; the lock may come first
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
let reclaimed;
|
|
119
|
+
for (let attempt = 0; ; attempt++) {
|
|
120
|
+
try { mkdirSync(dir); break; }
|
|
121
|
+
catch (e) {
|
|
122
|
+
if (e.code !== "EEXIST") throw e;
|
|
123
|
+
const owner = readOwner(dir);
|
|
124
|
+
const opts = { host, liveness, reclaimHostless };
|
|
125
|
+
if (attempt === 0 && owner?.pid !== pid && deadHere(owner, opts)) {
|
|
126
|
+
const r = reclaimDeadLock(dir, owner, { pid, nonce, host }, opts);
|
|
127
|
+
if (r.abandoned) {
|
|
128
|
+
const { guard, pid: reclaimer } = r.abandoned;
|
|
129
|
+
return { path: dir, held: { pid: owner.pid, startedAt: owner.startedAt, liveness: "dead", guard,
|
|
130
|
+
recovery: `${guard} was left by pid ${reclaimer}, which died while reclaiming ${dir}; once no capture process is running (pgrep -f capture.mjs), remove both with: rm -- ${shellQuote(guard)}; rm -r -- ${shellQuote(dir)} and rerun` } };
|
|
131
|
+
}
|
|
132
|
+
// Gone, whoever removed it (another reclaimer may have): try the lock once more.
|
|
133
|
+
if (r.removed) reclaimed = { pid: owner.pid, startedAt: owner.startedAt };
|
|
134
|
+
if (r.removed || !existsSync(dir)) continue;
|
|
135
|
+
}
|
|
136
|
+
const now = readOwner(dir);
|
|
137
|
+
// Gone between the mkdir and this read (a release or a reclaim): try once more. A directory that
|
|
138
|
+
// exists without a record is initializing or mid-removal, and is reported so.
|
|
139
|
+
if (!now && attempt === 0 && !existsSync(dir)) continue;
|
|
140
|
+
const live = now ? (now.pid === pid ? "alive" : liveness(now.pid)) : "unknown";
|
|
141
|
+
return { path: dir, ...(reclaimed ? { reclaimed } : {}), held: { pid: now?.pid, startedAt: now?.startedAt, liveness: live, recovery: recoveryInstruction(dir, now, live) } };
|
|
142
|
+
}
|
|
76
143
|
}
|
|
77
|
-
const nonce = randomBytes(8).toString("hex");
|
|
78
144
|
let directoryFd, identity;
|
|
79
145
|
try {
|
|
80
146
|
// Keep the directory alive until initialization or its cleanup finishes.
|
|
@@ -82,7 +148,7 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
|
|
|
82
148
|
// a record-less replacement look like the directory we created.
|
|
83
149
|
directoryFd = fs.openSync(dir, "r");
|
|
84
150
|
identity = fstatSync(directoryFd);
|
|
85
|
-
fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString() }));
|
|
151
|
+
fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString(), host }));
|
|
86
152
|
} catch (err) {
|
|
87
153
|
// Ownership was proven by the mkdir, not by the moment of cleanup: the
|
|
88
154
|
// directory is removed only if it is still ours (same inode) and holds
|
|
@@ -109,6 +175,7 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
|
|
|
109
175
|
}
|
|
110
176
|
return {
|
|
111
177
|
path: dir,
|
|
178
|
+
...(reclaimed ? { reclaimed } : {}),
|
|
112
179
|
release: () => {
|
|
113
180
|
if (!existsSync(dir)) return { released: false, reason: "gone" };
|
|
114
181
|
const cur = readOwner(dir);
|
|
@@ -61,7 +61,7 @@ members:
|
|
|
61
61
|
- git:github.com/acme/platform
|
|
62
62
|
packages:
|
|
63
63
|
oats.framework: v1.4.1 # bare versions resolve through the official catalog
|
|
64
|
-
oats.okf: v4.0.
|
|
64
|
+
oats.okf: v4.0.7
|
|
65
65
|
defaults:
|
|
66
66
|
capabilities: { oats.core: { from: package } }
|
|
67
67
|
knowledge: { oats.okf: { from: package } }
|