@awebai/oats 0.35.0 → 0.35.1

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/desktop.md CHANGED
@@ -81,6 +81,9 @@ Desktop never parses the deployment: its members, lock state and header come
81
81
  from `oats workspace status`, and its instances from the deployment's one
82
82
  `agents/` root.
83
83
  Added workspaces are remembered and offered as suggestions next time.
84
+ **Browse** opens in the folder holding the workspace most recently added or
85
+ opened, or your home directory when there is none (never ~/Downloads);
86
+ **Choose oats…** opens in the directory of the chosen or discovered CLI.
84
87
 
85
88
  Launch flags for scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
86
89
 
@@ -237,3 +237,20 @@ contacts GitHub, aweb, Jira or Linear.
237
237
 
238
238
  Tests pin behaviour, so a change that alters behaviour changes its test in the
239
239
  same commit. Never weaken an assertion to make a change pass.
240
+
241
+ Since Git 2.47, a commit or fetch starts `git maintenance run --auto
242
+ --detach`, a daemon that outlives the command. On a loaded CI runner it can
243
+ still be repacking into a repository while the test's cleanup removes it,
244
+ which fails with `ENOTEMPTY` (awebai/oats#451). So:
245
+
246
+ - The shared fixture's repositories (`test/helpers/v2-deployment.mjs`: the
247
+ bare remote, the seed and the member clone) never run it. Each sets
248
+ `maintenance.auto=false` in its own config, which covers every git run in
249
+ it, a test's raw `execFileSync("git", …)` and the kernel's included.
250
+ - A test that runs git itself goes through the fixture's `git()` helper,
251
+ which disables it per command, or sets `maintenance.auto=false` in a
252
+ repository it creates.
253
+ - The kernel's remote cache passes `-c maintenance.auto=false` on every git
254
+ it runs there.
255
+
256
+ Never paper over such a race with a retry around the cleanup.
@@ -0,0 +1,42 @@
1
+ # OATS 0.35.1
2
+
3
+ ## Fixed
4
+
5
+ - **The remote cache no longer leaves a background git maintenance running
6
+ after a fetch** (awebai/oats#451). Since Git 2.47, a `git fetch` starts
7
+ `git maintenance run --auto --detach`, a daemon that outlives the fetch.
8
+ Every fetch the kernel made into its remote cache (`<cache>/<hash>`)
9
+ started one, which could still be repacking the cache after the command
10
+ that fetched had returned. The cache is the kernel's own repository and
11
+ never runs gc, so its git now runs with `maintenance.auto=false` as well.
12
+ The same daemon, started by a test's commit, was what made a retire test
13
+ fail its cleanup with `ENOTEMPTY` in CI; the test fixtures' repositories
14
+ turn it off too.
15
+ - **Desktop's folder pickers open in a sensible place** (awebai/oats#460).
16
+ **Add workspace → Browse…** opened wherever macOS last was, often
17
+ ~/Downloads. It now opens in the folder holding the workspace most
18
+ recently added or opened, remembered across launches, and in the home
19
+ directory before the first one. **Choose oats…** opens in the directory
20
+ of the chosen or discovered CLI.
21
+
22
+ - **A tree object the remote cache cannot read is a problem, never an empty
23
+ directory, and the cache heals** (awebai/oats#455). A fetch into the cache
24
+ that failed partway (on one host, one run by hand) left a member's commit
25
+ without its `souls/` tree. git answers "names no tree" the same way for an
26
+ absent path and for a tree object it cannot read, and the kernel took both
27
+ as an absent directory. So the member enumerated to zero souls with no
28
+ problem, the parsed cache (`<cache>/.parsed/`) kept that answer, and every
29
+ later command hid the member's souls (`E_SOUL_UNKNOWN`). Because a present
30
+ commit is never refetched, the cache stayed broken. Now:
31
+ - an absent directory is confirmed against the commit's own listing;
32
+ - a tree the commit lists but that cannot be read is `E_REMOTE_UNREADABLE`
33
+ (reason `cache`). It is shown as a problem and never kept in the parsed
34
+ cache;
35
+ - the read marks that cache repository damaged, and the next command
36
+ rebuilds it and fetches afresh.
37
+
38
+ Upgrading also stops a bad answer kept by an earlier kernel from being
39
+ served: the parsed cache is keyed by the kernel's own code.
40
+
41
+ Never fetch into `~/.cache/oats/remotes` by hand. It is the kernel's cache,
42
+ written only under its own locks.
package/lib/remote.mjs CHANGED
@@ -696,7 +696,9 @@ function redactUrl(url) {
696
696
  }
697
697
 
698
698
  function repoHandle(dir, exec, session) {
699
- const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
699
+ // The cache is the kernel's own: no gc, and no `git maintenance run --auto --detach` (Git >= 2.47) left
700
+ // repacking it after the command that fetched has returned (awebai/oats#451).
701
+ const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
700
702
  return { dir, exec, local, session };
701
703
  }
702
704
 
@@ -781,6 +783,7 @@ class ReadSession {
781
783
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
782
784
  this.peels = new Map(); // `${cacheRepo}\0${oid}` → peeled commit (positive answers only)
783
785
  this.trees = new Map(); // `${cacheRepo}\0${commit}` → Promise<tree index | null>
786
+ this.damaged = new Set(); // cache repos this session found damaged: rebuilt by the next one, not mid-command
784
787
  this.batches = new Map(); // cacheRepo → batch reader, in least-recently-used order
785
788
  this.retiring = new Set(); // evicted batch readers still ending
786
789
  this.parsedDirs = new Set(); // fingerprint dirs touched (mtime bumped) this session
@@ -1173,7 +1176,7 @@ async function ensureCommit(ref, oid, options) {
1173
1176
  // Already present (the common case): a read, so no write lock.
1174
1177
  let peeled = false; // the object store was asked already, and said no
1175
1178
  const usable = async (repo) => {
1176
- if (!existsSync(join(dir, "HEAD"))) return null;
1179
+ if (!existsSync(join(dir, "HEAD")) || toRebuild(dir, session)) return null;
1177
1180
  if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
1178
1181
  peeled = true;
1179
1182
  return peelCommit(repo, oid);
@@ -1187,6 +1190,7 @@ async function ensureCommit(ref, oid, options) {
1187
1190
  const version = await readVersion(repo, ref);
1188
1191
  // A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
1189
1192
  if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
1193
+ if (toRebuild(repo.dir, session)) repo = await freshCache(repo, ref, options);
1190
1194
  const cached = peeled && !waited ? null : await peelCommit(repo, oid);
1191
1195
  if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
1192
1196
  const mode = await fetchMode(repo)
@@ -1300,18 +1304,45 @@ export function keepsPartialCache(version) {
1300
1304
  /** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
1301
1305
  * it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
1302
1306
  async function rebuildWhole(repo, ref, options, version) {
1303
- const session = repo.session;
1304
- if (session) {
1305
- session.batches.get(repo.dir)?.kill();
1306
- session.batches.delete(repo.dir);
1307
- for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
1308
- }
1309
- rmSync(repo.dir, { recursive: true, force: true });
1310
- const fresh = await cacheRepo(ref, options);
1307
+ const fresh = await freshCache(repo, ref, options);
1311
1308
  await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
1312
1309
  return fresh;
1313
1310
  }
1314
1311
 
1312
+ /** Drop a cache repository and start it afresh (under the cache's write lock); the session forgets it. */
1313
+ async function freshCache(repo, ref, options) {
1314
+ forgetCacheRepo(repo);
1315
+ rmSync(repo.dir, { recursive: true, force: true });
1316
+ return cacheRepo(ref, options);
1317
+ }
1318
+
1319
+ /** The session's peels, tree indexes and batch reader of this cache repository: what it read there is void. */
1320
+ function forgetCacheRepo(repo) {
1321
+ const session = repo.session;
1322
+ if (!session) return;
1323
+ session.batches.get(repo.dir)?.kill();
1324
+ session.batches.delete(repo.dir);
1325
+ for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
1326
+ }
1327
+
1328
+ /** A cache repository that holds a commit without a tree of it (a fetch into it that failed partway, say: a
1329
+ * present commit is never refetched) is marked damaged by the read that finds it, and the next ensureCommit
1330
+ * rebuilds it (awebai/oats#455). The mark lives in the repository, so it goes with it. The session that found
1331
+ * the damage keeps the repository as it is and reports problems: rebuilding it under that command's other
1332
+ * reads would remove what they are reading. The next session (or a read without one) rebuilds, inside
1333
+ * ensureCommit's cache write lock as every other cache write. The mark is a plain file in the bare
1334
+ * repository's root, which git ignores (as it does FETCH_HEAD or gc.log); writing it needs no lock: it only
1335
+ * asks for a rebuild, and a rebuild that already happened took the mark with it. */
1336
+ const DAMAGED_MARK = "oats-damaged";
1337
+ function markCacheDamaged(repo, why) {
1338
+ try { writeFileSync(join(repo.dir, DAMAGED_MARK), `${why}\n`); } catch { /* the read's problem stands either way */ }
1339
+ // The finding session keeps its reader, peels and tree indexes of the repository: they are still true, and
1340
+ // freshCache forgets them when the repository actually goes.
1341
+ repo.session?.damaged.add(repo.dir);
1342
+ }
1343
+ /** The repository carries a damage mark this session did not set: the next fetch rebuilds it first. */
1344
+ const toRebuild = (dir, session) => existsSync(join(dir, DAMAGED_MARK)) && !session?.damaged.has(dir);
1345
+
1315
1346
  /** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
1316
1347
  * a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
1317
1348
  * failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
@@ -1696,7 +1727,13 @@ function assertNoCollisions(entries, ref, commit, prefix) {
1696
1727
  /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
1697
1728
  * Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
1698
1729
  * for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
1699
- * row and would otherwise abort the whole discovery on one unexplained listing (L4). */
1730
+ * row and would otherwise abort the whole discovery on one unexplained listing (L4).
1731
+ *
1732
+ * git answers "names no tree" the same way for a path that is absent and for a tree object it cannot read
1733
+ * (`<commit>:souls` with that tree missing is "not a tree object"; a path below it is "not a valid object
1734
+ * name"), so the message is never trusted alone: null only when the commit's own listing confirms the path is
1735
+ * absent or not a tree (awebai/oats#455). A tree it lists but cannot read is E_REMOTE_UNREADABLE, which no
1736
+ * parsed-cache item is ever written with: an absent directory would be cached as zero souls. */
1700
1737
  async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
1701
1738
  try {
1702
1739
  const args = ["ls-tree", "-l", "-z", ...flags, spec];
@@ -1706,11 +1743,54 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 6
1706
1743
  } catch (error) {
1707
1744
  if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
1708
1745
  const text = stderrText(error).toLowerCase();
1709
- if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
1710
- const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
1711
- const reason = error?.timedOut ? "timeout" : killed ? "killed" : "unknown";
1712
- const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1713
- throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
1746
+ const namesNoTree = /not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text);
1747
+ if (namesNoTree) {
1748
+ let absent;
1749
+ // The confirming listing's own failure (a timeout, a kill, the session's abort) is classified as any listing
1750
+ // failure is: it says nothing about the tree.
1751
+ try { absent = await namesNoTreeIn(repo, spec); }
1752
+ catch (confirmFailed) { throw listingUnreadable(confirmFailed, { repo, spec, path, ref, commit }); }
1753
+ if (absent) return null;
1754
+ const bare = !spec.includes(":");
1755
+ markCacheDamaged(repo, bare ? `${spec}: the commit cannot be read` : `${spec}: listed by its commit, unreadable`);
1756
+ throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec} in ${ref?.key ?? repo.dir} (cache: ${bare ? "the commit's own tree cannot be read" : "its commit lists a tree there that cannot be read"})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason: "cache", commit: commit ?? null, spec, path: path ?? null });
1757
+ }
1758
+ throw listingUnreadable(error, { repo, spec, path, ref, commit });
1759
+ }
1760
+ }
1761
+
1762
+ /** A failed `git ls-tree` as E_REMOTE_UNREADABLE: reason "timeout" for the timeout kill, "killed" for any other
1763
+ * signal exit, "cache" for an object the cache lacks (a tree below the commit that git cannot read: the
1764
+ * repository is marked damaged), else "unknown". An oats error passes through as itself. */
1765
+ function listingUnreadable(error, { repo, spec, path, ref, commit }) {
1766
+ if (typeof error?.code === "string" && error.code.startsWith("E_")) return error;
1767
+ const text = stderrText(error).toLowerCase();
1768
+ const missingObject = /could not read [0-9a-f]{40,64}|missing (?:tree|blob|object) [0-9a-f]{40,64}|bad tree object/.test(text);
1769
+ if (missingObject) markCacheDamaged(repo, `${spec}: ${text.trim().split("\n")[0]}`);
1770
+ const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
1771
+ const reason = error?.timedOut ? "timeout" : killed ? "killed" : missingObject ? "cache" : "unknown";
1772
+ const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1773
+ return fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
1774
+ }
1775
+
1776
+ /** Whether `<commit>:<rel>` really names no tree: the commit's own listing of <rel>, which reads every tree on
1777
+ * the way, has no entry there, or a non-tree one. A bare commit spec, a tree entry, or a listing git ran and
1778
+ * failed means the tree is there and unreadable: false. A listing that did not run to its end (a timeout,
1779
+ * a kill, the session's abort, an oats error) answers nothing: it is thrown, for lsTree to classify. */
1780
+ async function namesNoTreeIn(repo, spec) {
1781
+ const colon = spec.indexOf(":");
1782
+ if (colon < 0) return false;
1783
+ const rel = spec.slice(colon + 1);
1784
+ if (!rel) return false;
1785
+ try {
1786
+ const out = await repo.local(["ls-tree", "-l", "-z", spec.slice(0, colon), "--", rel]);
1787
+ const entry = parseLsTree(out.stdout).find((e) => e.path === rel);
1788
+ return !entry || entry.type !== "tree";
1789
+ } catch (error) {
1790
+ const unfinished = error?.timedOut || error?.overflowed || (typeof error?.signal === "string" && error.signal !== "")
1791
+ || error?.code === "ABORT_ERR" || (typeof error?.code === "string" && error.code.startsWith("E_"));
1792
+ if (unfinished) throw error;
1793
+ return false;
1714
1794
  }
1715
1795
  }
1716
1796
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
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",