@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 +3 -0
- package/docs/implementation.md +17 -0
- package/docs/release-notes/v0.35.1.md +42 -0
- package/lib/remote.mjs +96 -16
- package/package.json +1 -1
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
|
|
package/docs/implementation.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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.
|
|
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",
|