@awebai/oats 0.34.2 → 0.34.3
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/docs/capabilities.md +2 -1
- package/docs/desktop-cli-api.md +11 -7
- 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/souls-and-instances.md +8 -2
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +36 -22
- 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/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
|
|
|
@@ -2028,7 +2028,11 @@ A first retire prints the **raw receipt**, not an envelope:
|
|
|
2028
2028
|
- `--discard-worktree` removes the worktree. `--delete-branch` deletes the
|
|
2029
2029
|
worktree's verified branch (re-verified at deletion time) and implies
|
|
2030
2030
|
discarding; a mismatch deletes nothing and reports
|
|
2031
|
-
`branchDeletionSkipped`.
|
|
2031
|
+
`branchDeletionSkipped`. Without `--delete-branch` no retire deletes a
|
|
2032
|
+
branch, a retried or `--force`d quarantine included. A failed spawn's
|
|
2033
|
+
quarantine that still owes the branch the spawn created stays incomplete
|
|
2034
|
+
(`git branch <b>: kept; the failed spawn created it; pass --delete-branch to
|
|
2035
|
+
delete it`).
|
|
2032
2036
|
- `workRecovery` (or `workRecoveries[]`): `{path, classes, bytes, outputs?,
|
|
2033
2037
|
repoCopy?}`; `outputs: {paths: [{path, bytes}], bytes}` names what was
|
|
2034
2038
|
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.
|
|
@@ -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,12 @@ 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
|
+
Retire never deletes a branch unless you pass `--delete-branch`, and then
|
|
436
|
+
only the verified branch: not on a quarantine, its retry or `--force`. A
|
|
437
|
+
spawn that fails deletes the branch it created only while the branch's tip
|
|
438
|
+
is still where the spawn created it. If something was committed there, the
|
|
439
|
+
branch is kept and the failure says so.
|
|
440
|
+
|
|
435
441
|
## Work modes
|
|
436
442
|
|
|
437
443
|
A work mode decides what `./work` points at and what discipline the agent must
|
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
|
@@ -3395,11 +3395,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3395
3395
|
const list = run(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
|
|
3396
3396
|
if (!list.ok) incomplete.push(`git worktree ${wt}: could not verify removal (${list.err || "worktree list failed"})`);
|
|
3397
3397
|
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}`})`);
|
|
3398
|
+
incomplete.push(...deleteBranchAsCreated(run, repoAbs, branch, plannedBase.oid));
|
|
3403
3399
|
}
|
|
3404
3400
|
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
3405
3401
|
const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
|
|
@@ -3576,10 +3572,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3576
3572
|
if (worktreeCanonical && registered.includes(worktreeCanonical)) { incomplete.push(`git worktree ${worktreeCanonical}: still registered`); outstandingGit.add("worktree"); }
|
|
3577
3573
|
}
|
|
3578
3574
|
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"); }
|
|
3575
|
+
const branchDebt = deleteBranchAsCreated(probe, repoAbs, branch, plannedBase.oid);
|
|
3576
|
+
if (branchDebt.length) { incomplete.push(...branchDebt); outstandingGit.add("branch"); }
|
|
3583
3577
|
}
|
|
3584
3578
|
}
|
|
3585
3579
|
// Any attempted spawn hook may have created state before a later hook (or
|
|
@@ -4114,6 +4108,17 @@ function sessionDirectoryGuard(home) {
|
|
|
4114
4108
|
return check;
|
|
4115
4109
|
}
|
|
4116
4110
|
|
|
4111
|
+
/** A failed spawn's compare-and-delete of the branch it created at `oid`: `git update-ref -d` refuses
|
|
4112
|
+
* atomically if the tip moved (something was committed there), and the branch is then kept. `run`
|
|
4113
|
+
* answers { ok, out, status, err }. → what is still owed, as messages ([] when the branch is gone). */
|
|
4114
|
+
function deleteBranchAsCreated(run, repoAbs, branch, oid) {
|
|
4115
|
+
const del = run(["git", "-C", repoAbs, "update-ref", "-d", `refs/heads/${branch}`, oid]);
|
|
4116
|
+
const ref = run(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
4117
|
+
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`];
|
|
4118
|
+
if (ref.ok) return [`git branch ${branch}: still exists${del.ok ? "" : ` (deletion failed: ${del.err || `exit ${del.status}`})`}`];
|
|
4119
|
+
if (ref.status !== 1 || ref.err) return [`git branch ${branch}: could not verify deletion (${ref.err || `exit ${ref.status}`})`];
|
|
4120
|
+
return [];
|
|
4121
|
+
}
|
|
4117
4122
|
/** Retain the home and its cleanup receipt when spawn compensation or retirement
|
|
4118
4123
|
* cannot finish. Keeping the original credentials makes cleanup retryable. */
|
|
4119
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) }) {
|
|
@@ -5938,7 +5943,6 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5938
5943
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
5939
5944
|
// retry becomes the deletion the quarantine was preventing.
|
|
5940
5945
|
let stillIncomplete;
|
|
5941
|
-
let quarantineBranchDeleted = false;
|
|
5942
5946
|
// Ordinary path: quarantine instead of deleting, using the SAME writer the
|
|
5943
5947
|
// spawn rollback uses. Two copies of this logic is how a previous divergence
|
|
5944
5948
|
// happened (see quarantineInstanceHome), so there is still exactly one.
|
|
@@ -5963,9 +5967,8 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5963
5967
|
if (quarantine) {
|
|
5964
5968
|
const failures = (hookResults?.failures || []).map((f) => `retire hook ${f.capability}: ${f.message}`);
|
|
5965
5969
|
// 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.
|
|
5970
|
+
// redo those steps and verify them — not just rerun hooks. A branch is never
|
|
5971
|
+
// deleted here: only --delete-branch deletes one (the verified branch, above).
|
|
5969
5972
|
if (meta.work === "worktree" && meta.repo) {
|
|
5970
5973
|
const gitProbe = (argv) => {
|
|
5971
5974
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
@@ -5980,14 +5983,25 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5980
5983
|
const registered = wtProbe.out.split("\0").filter((f) => f.startsWith("worktree ")).map((f) => f.slice("worktree ".length));
|
|
5981
5984
|
if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
|
|
5982
5985
|
}
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5986
|
+
// The branch is a debt only when the failed spawn's rollback still owes its deletion, or when the
|
|
5987
|
+
// operator asked for it (--delete-branch); then it must be verified gone, and a branch kept is said.
|
|
5988
|
+
// --delete-branch: the branch the documented path deleted (the verified one) must be gone. A failed
|
|
5989
|
+
// spawn's quarantine that owes its recorded branch keeps it unless that branch was the one deleted.
|
|
5990
|
+
const verify = (branch) => {
|
|
5991
|
+
const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
5992
|
+
if (br.ok) return "exists";
|
|
5993
|
+
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"; }
|
|
5994
|
+
return "gone";
|
|
5995
|
+
};
|
|
5996
|
+
const deleted = retention?.branchDeleted;
|
|
5997
|
+
if (deleted && verify(deleted) === "exists") failures.push(`git branch ${deleted}: still exists`);
|
|
5998
|
+
const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
|
|
5999
|
+
if (owesBranch && meta.branch && meta.branch !== deleted && verify(meta.branch) === "exists") {
|
|
6000
|
+
// Without a home left to retry from (--force), or when --delete-branch verified another branch, the
|
|
6001
|
+
// recorded branch is the operator's to delete by hand.
|
|
6002
|
+
failures.push(o.force || o.deleteBranch
|
|
6003
|
+
? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
|
|
6004
|
+
: `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
|
|
5991
6005
|
}
|
|
5992
6006
|
}
|
|
5993
6007
|
for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
|
|
@@ -6039,7 +6053,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6039
6053
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6040
6054
|
}
|
|
6041
6055
|
|
|
6042
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted)
|
|
6056
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted), removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
|
|
6043
6057
|
const w = [...(hookResults?.warnings || [])];
|
|
6044
6058
|
if (isCapturedHome(meta) && !quarantine) {
|
|
6045
6059
|
// A captured home retires through the workspace path; its captured retire hooks do not
|
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.3",
|
|
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 } }
|