@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.
@@ -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 rollback-owned Git steps, and verifying both. A retry that still cannot
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
@@ -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.6","ref":"v4.0.6"}}],
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.6","kind":"package","package":"oats.okf",
786
- "version":"4.0.6","repoKey":"github.com/awebai/oats-okf","commit":"2a62df8e…","teams":null,"defaultTeam":null,"private":false,
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.6",
876
- "commit":"2a62df8e…","path":"oats-package/capabilities/oats-okf",
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":"2a62df8e…",
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.
@@ -40,8 +40,8 @@ arrives from.
40
40
  ```yaml
41
41
  # oats-workspace.yaml: one default per slot, for every soul
42
42
  packages:
43
- oats.okf: v4.0.6
44
- oats.aweb: v1.17.6
43
+ oats.okf: v4.0.7
44
+ oats.aweb: v1.17.7
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
47
47
  defaults:
package/docs/knowledge.md CHANGED
@@ -26,7 +26,7 @@ The workspace pins the package and fills the slot for every soul by default:
26
26
  ```yaml
27
27
  # oats-workspace.yaml (excerpt)
28
28
  packages:
29
- oats.okf: v4.0.6
29
+ oats.okf: v4.0.7
30
30
  defaults:
31
31
  knowledge: { oats.okf: { from: package } }
32
32
  stores:
@@ -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.6` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
- | `oats.aweb` | `v1.17.6` | `oats.aweb` (messaging) | |
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.6`); `oats sync` resolves it through the
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.6 # bare version → the official catalog
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.6`, `4.0.6`, `1.0.0-rc.1`): the id is looked up in
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.6` or `oats-framework/v1.4.1`) and the payload path. An id
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.6
79
- oats.aweb: v1.17.6
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.6 ✓ (@ 2a62df8e)
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.6 # a catalog version
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.6",
164
- "commit": "2a62df8eb247b58b3d89b2ce38d84c49c2d8ae23",
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.6", "path": "oats-package" },
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.6", "commit": "2a62df8e…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
- "commit": "2a62df8e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
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
@@ -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.6
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
- const del = run(["git", "-C", repoAbs, "branch", "-D", branch]);
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(["git", "-C", repoAbs, "branch", "-D", branch]);
3580
- const brProbe = probe(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
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. The branch is
5967
- // rollback-owned (spawn created it), so it is deleted here without needing
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
- if (meta.branch) {
5984
- gitProbe(["git", "-C", meta.repo, "branch", "-D", meta.branch]);
5985
- const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${meta.branch}`]);
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;
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) || quarantineBranchDeleted, 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: (() => {
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
@@ -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",
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.6",
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.2",
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) is written
8
- // inside it after the mkdir. Nothing here ever steals a lock: any existing
9
- // lock, live, dead, unknowable or still initializing, refuses the pass and
10
- // names the holder and the operator recovery. A stale lock after a killed
11
- // pass is removed by the operator once the pid is verified gone; the
12
- // message says exactly that. (A reclaim protocol was reviewed and rejected:
13
- // rename is not compare-and-swap, and stealing from a stalled live
14
- // initializer under memory pressure is the failure we are preventing.)
15
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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
- function readOwner(dir) {
28
- try { return JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { return undefined; }
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 } when
44
- * taken, or { path, held: { pid, startedAt, liveness, recovery } } when any
45
- * lock exists. Never removes a lock it did not create.
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
- try {
70
- mkdirSync(dir);
71
- } catch (e) {
72
- if (e.code !== "EEXIST") throw e;
73
- const owner = readOwner(dir);
74
- const live = owner ? (owner.pid === pid ? "alive" : liveness(owner.pid)) : "unknown";
75
- return { path: dir, held: { pid: owner?.pid, startedAt: owner?.startedAt, liveness: live, recovery: recoveryInstruction(dir, owner, live) } };
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.6
64
+ oats.okf: v4.0.7
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }
67
67
  knowledge: { oats.okf: { from: package } }