@awebai/oats 0.34.0 → 0.34.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/bin/oats.mjs +22 -10
- package/docs/execution-targets.md +3 -3
- package/docs/implementation.md +23 -1
- package/docs/knowledge.md +4 -2
- package/docs/official-catalog.md +1 -1
- package/docs/release-notes/v0.34.1.md +57 -0
- package/docs/souls-and-instances.md +15 -8
- package/docs/workspaces.md +12 -1
- package/lib/capability-contract.mjs +1 -1
- package/lib/core.mjs +3 -3
- package/lib/instance-resolution.mjs +26 -4
- package/lib/remote.mjs +116 -9
- package/package-catalog.json +1 -1
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -391,7 +391,7 @@ async function workspaceTarget(bail, { command, liveTeams = true }) {
|
|
|
391
391
|
try { meta = JSON.parse(readFileSync(join(homeFlag, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeFlag} is not an OATS instance home (${e.code === "ENOENT" ? "no instance.json" : e.message})`); }
|
|
392
392
|
if (isCapturedHome(meta)) { const e = capturedHomeRefusal(homeFlag, "nothing was read"); return bail(e.code, e.message, e.details); }
|
|
393
393
|
if (!meta || typeof meta.modules !== "object" || meta.modules === null) return bail("E_UNSUPPORTED_MODE", `${homeFlag} is not a workspace-model home (it records no modules): it was spawned by an earlier kernel — re-spawn it from the deployment`);
|
|
394
|
-
if (soulFlag && soulFlag
|
|
394
|
+
if (soulFlag && !(await import("../lib/instance-resolution.mjs")).homeSoulMatches(soulFlag, meta)) return bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${homeFlag} (${meta.agent})`);
|
|
395
395
|
const deployment = dirname(dirname(dirname(dirname(realOrResolved(homeFlag)))));
|
|
396
396
|
// A v2 home lives at <deployment>/agents/<soul>/instances/<name>: its deployment is
|
|
397
397
|
// derived, so it must hold oats-local.yaml EXACTLY there (never found by walking up).
|
|
@@ -537,8 +537,9 @@ async function workspaceOperation(t, { bail, address, layer, opName }) {
|
|
|
537
537
|
const settings = lp.settings;
|
|
538
538
|
const cwd = op.context === "home" ? t.home : t.deployment;
|
|
539
539
|
const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
|
|
540
|
-
|
|
541
|
-
|
|
540
|
+
// The home's identity, never one inherited from the caller (the reserved PI_AGENT_* names included).
|
|
541
|
+
for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
|
|
542
|
+
if (op.context === "home") Object.assign(env, { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home, OATS_HOME: t.home });
|
|
542
543
|
await readSession?.closeBatches(); // no idle `git cat-file --batch` child held for the provider's whole run
|
|
543
544
|
const r = spawnSync("node", [abs, ...rest, ...argFlags, "--json"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: OPERATION_TIMEOUT_MS, killSignal: "SIGTERM" });
|
|
544
545
|
finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
|
|
@@ -2324,8 +2325,8 @@ function retireCmd() {
|
|
|
2324
2325
|
}
|
|
2325
2326
|
// The calling instance knows its own home: self-retire never needs to
|
|
2326
2327
|
// disambiguate a same-named twin by hand.
|
|
2327
|
-
if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME &&
|
|
2328
|
-
const isSelf = process.env.
|
|
2328
|
+
if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && process.env.OATS_INSTANCE === name) homeFlag = process.env.OATS_INSTANCE_HOME;
|
|
2329
|
+
const isSelf = process.env.OATS_INSTANCE === name;
|
|
2329
2330
|
if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
|
|
2330
2331
|
if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
|
|
2331
2332
|
const root = ensureRoot(dirFlag());
|
|
@@ -2851,9 +2852,12 @@ async function capabilityCommand() {
|
|
|
2851
2852
|
let activeIds;
|
|
2852
2853
|
let context = process.cwd();
|
|
2853
2854
|
let teamCtx, homeMeta, homeTeamCtx;
|
|
2854
|
-
// OATS_INSTANCE_HOME is the
|
|
2855
|
-
//
|
|
2856
|
-
|
|
2855
|
+
// OATS_INSTANCE_HOME is the identity; OATS_HOME still counts. With neither set (a harness that
|
|
2856
|
+
// strips the session env), the home enclosing the cwd. What chose the home is named in every
|
|
2857
|
+
// refusal about it.
|
|
2858
|
+
const homeVariable = ["OATS_INSTANCE_HOME", "OATS_HOME"].find((name) => process.env[name]);
|
|
2859
|
+
const instanceHome = homeVariable ? process.env[homeVariable] : enclosingInstanceHome(logicalCwd());
|
|
2860
|
+
const chosenBy = homeVariable ?? "the working directory";
|
|
2857
2861
|
const metaFile = instanceHome && join(instanceHome, "instance.json");
|
|
2858
2862
|
// Capability-id keyed — never answer for `constructor`/`toString`. Belt and
|
|
2859
2863
|
// braces: the ids come from instance.json, which spawn wrote from resolved
|
|
@@ -2872,11 +2876,19 @@ async function capabilityCommand() {
|
|
|
2872
2876
|
if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
|
|
2873
2877
|
context = meta.repo || context;
|
|
2874
2878
|
soulDir = instanceSoulDir(instanceHome, meta);
|
|
2879
|
+
const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
|
|
2880
|
+
// Inside a home the namespace is the home's, so a --soul for another soul is refused, never
|
|
2881
|
+
// ignored (as inspect's --soul against --home is). The home's own soul, by any of its names, is fine.
|
|
2882
|
+
const soulFlag = flag("soul");
|
|
2883
|
+
if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name", { flag: "--soul" });
|
|
2884
|
+
if (typeof soulFlag === "string" && !(await import("../lib/instance-resolution.mjs")).homeSoulMatches(soulFlag, meta)) {
|
|
2885
|
+
bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of the instance home ${instanceHome} (${meta.agent}), which ${chosenBy} chose; to run "${cmd}" as a spawn of ${soulFlag} would, run it outside the instance home with OATS_INSTANCE_HOME and OATS_HOME unset`,
|
|
2886
|
+
{ home: instanceHome, soul: meta.agent, chosenBy, flag: "--soul" });
|
|
2887
|
+
}
|
|
2875
2888
|
// The team/workspace facts the home recorded at spawn, as its hooks got them, with the
|
|
2876
2889
|
// recorded eligible teams (OATS_TEAMS_SOURCE=recorded). Only the home's MESSAGING module
|
|
2877
2890
|
// gets them live (below): its team verbs (join/leave/teams) must see what the workspace
|
|
2878
2891
|
// allows now, and no other command pays a remote read for them.
|
|
2879
|
-
const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
|
|
2880
2892
|
const messaging = (meta.capabilities || []).find((c) => c.layer === "messaging")?.id;
|
|
2881
2893
|
homeMeta = { meta, messaging };
|
|
2882
2894
|
homeTeamCtx = (t) => teamEnv({ workspace: { key: ws.key, name: ws.name, deployment: ws.deployment }, teams: t.teams, defaultTeam: t.defaultTeam, teamsSource: t.source });
|
|
@@ -2894,7 +2906,7 @@ async function capabilityCommand() {
|
|
|
2894
2906
|
// Workspace model: an instance's own materialized modules are the command
|
|
2895
2907
|
// namespaces available to it (instance.json.modules → <home>/.oats/modules).
|
|
2896
2908
|
const mans = Object.values(capabilityManifests(instanceHome)).filter((m) => m.command === cmd && m.commands);
|
|
2897
|
-
if (!mans.length)
|
|
2909
|
+
if (!mans.length) bail("E_UNKNOWN_COMMAND", `oats ${cmd}: no capability of the instance home ${instanceHome} (soul ${homeMeta.meta.agent}), which ${chosenBy} chose, provides "${cmd}"`, { home: instanceHome, chosenBy, namespace: cmd });
|
|
2898
2910
|
if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
|
|
2899
2911
|
const m = mans[0];
|
|
2900
2912
|
if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
|
|
@@ -27,9 +27,9 @@ named launch configuration (`--launch-config <name>`); see
|
|
|
27
27
|
[configuration.md](configuration.md#launch-configurations).
|
|
28
28
|
`--model @native-default` uses the harness's own default model.
|
|
29
29
|
|
|
30
|
-
The launch command sets `OATS_INSTANCE
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
The launch command sets `OATS_INSTANCE` and `OATS_INSTANCE_HOME`, plus the
|
|
31
|
+
environment that the launch configuration and capabilities contribute. No
|
|
32
|
+
`PI_AGENT_*` name is set, for any harness. The home's layout and what those
|
|
33
33
|
variables point at are described in
|
|
34
34
|
[souls-and-instances.md](souls-and-instances.md#instance-anatomy).
|
|
35
35
|
|
package/docs/implementation.md
CHANGED
|
@@ -72,6 +72,27 @@ Every CLI command owns one read session (`createReadSession` in
|
|
|
72
72
|
CLI closes it when the command ends). A library caller without a session
|
|
73
73
|
gets the plain per-call behaviour. Within a session:
|
|
74
74
|
|
|
75
|
+
- a HEAD observation speaks protocol v0 when the operator has not pinned
|
|
76
|
+
`protocol.version` (`git config --get`, read once per command): the whole
|
|
77
|
+
ref advertisement in one round trip, resolved exactly as v2's filtered
|
|
78
|
+
answer. `V0_ADVERTISEMENT_BUDGET` (4 MiB, git's `maxBuffer`) bounds what
|
|
79
|
+
is kept, not the transfer: git reads the whole advertisement before it
|
|
80
|
+
prints a ref, so a remote over budget costs its advertisement once, then
|
|
81
|
+
git is killed, the remote observed again under v2 and recorded. A v0
|
|
82
|
+
timeout stays today's error (no retry) and is recorded too, unless the
|
|
83
|
+
session's `deadline` cut that read's timeout (the deadline, not the remote,
|
|
84
|
+
may have ended it). The
|
|
85
|
+
v0 read and its `protocol.version` check go through `sessionExec` like every
|
|
86
|
+
other git call. The record is
|
|
87
|
+
`<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`, `{ protocol: "v2",
|
|
88
|
+
reason: "overflow" | "timeout", recordedAt }`, one file per reason (an
|
|
89
|
+
in-flight timeout never replaces an overflow), written atomically with no lock;
|
|
90
|
+
`overflow` is permanent, `timeout` expires after 7 days, and an unreadable,
|
|
91
|
+
corrupt or expired record is no record (v0 is tried). Another v0 failure
|
|
92
|
+
that is not final (auth, not-found, cache, an abort) is retried once under
|
|
93
|
+
v2. Each is a session notice. The v2 argv (`lsRemoteArgs`) stays the
|
|
94
|
+
observation's identity: records and memo keys do not depend on the
|
|
95
|
+
protocol;
|
|
75
96
|
- a head is observed once per (cache repo, ref), and a commit peeled once; at
|
|
76
97
|
most eight observations run at once (`OBSERVE_LIMIT`), each holding its slot
|
|
77
98
|
for all its git work (the `ls-remote` and the fetch of the commit it names,
|
|
@@ -107,7 +128,8 @@ gets the plain per-call behaviour. Within a session:
|
|
|
107
128
|
exit hook (`closeNow`), and the system reaps them once the process is gone;
|
|
108
129
|
- a session may have a `deadline` (`READ_REMOTE_BUDGET_MS`, 12 s after it
|
|
109
130
|
starts): the CLI gives one to `status` and `workspace status` only
|
|
110
|
-
(`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS`
|
|
131
|
+
(`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS` is a test and ops override,
|
|
132
|
+
not a contract). Every
|
|
111
133
|
remote step then gets what is left of it instead of its own default: each
|
|
112
134
|
git call's timeout (`sessionExec`: ls-remote, fetch, ls-tree, the cache's
|
|
113
135
|
plumbing; none starts once nothing is left), the git version probe
|
package/docs/knowledge.md
CHANGED
|
@@ -220,8 +220,10 @@ identical copies in both role capabilities.
|
|
|
220
220
|
- **From an instance home**, `oats okf …` runs the home's copy of the module
|
|
221
221
|
with the settings recorded at spawn.
|
|
222
222
|
- **From the deployment directory** (holding `oats-local.yaml`), in a shell
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
where neither `OATS_INSTANCE_HOME` nor `OATS_HOME` is set (either one pins
|
|
224
|
+
the command to that instance home), every capability command needs
|
|
225
|
+
`--soul <name>` (`E_BAD_ARGS` without it). Inside an instance home, a
|
|
226
|
+
`--soul` naming another soul is refused (`E_HOME_MISMATCH`). The kernel resolves the
|
|
225
227
|
soul as a spawn would, fetches its module at the locked commit into
|
|
226
228
|
`<deployment>/.oats/modules/` and runs it with the soul's merged settings.
|
|
227
229
|
An unlocked package is `E_PACKAGE_MISSING` until `oats sync`.
|
package/docs/official-catalog.md
CHANGED
|
@@ -12,7 +12,7 @@ or workspace membership alone does not make a package official.
|
|
|
12
12
|
| `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
|
|
13
13
|
| `oats.okf` | `v4.0.5` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
|
|
14
14
|
| `oats.aweb` | `v1.17.5` | `oats.aweb` (messaging) | |
|
|
15
|
-
| `oats.engineering` | `v1.
|
|
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) | |
|
|
18
18
|
| `oats.linear` | `v1.0.1` | `oats.linear` (tasks) | |
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# OATS 0.34.1
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **Reading a remote's current head takes one round trip instead of two.**
|
|
6
|
+
`oats spawn` (preview and apply) and the read verbs ask each workspace
|
|
7
|
+
member for its head with Git's protocol v0, which returns the whole ref list
|
|
8
|
+
in one request, where protocol v2 takes two. On a 6-member deployment the
|
|
9
|
+
live phase of a spawn preview went from 0.77 s to 0.49 s (median of 5). Tags
|
|
10
|
+
and branches are read as before. If your Git configuration sets
|
|
11
|
+
`protocol.version`, OATS uses it as set. A remote whose ref list is over
|
|
12
|
+
4 MiB is read with protocol v2 from then on (remembered in the remote
|
|
13
|
+
cache). A remote that times out under v0 fails as before and is read with
|
|
14
|
+
v2 for the next 7 days; a read ended by the 12 s budget of `oats status` or
|
|
15
|
+
`oats workspace status` is not counted as the remote's timeout. One that
|
|
16
|
+
fails under v0 for another reason than an
|
|
17
|
+
authentication refusal, a missing repository or the local cache is read
|
|
18
|
+
again with v2. Each case is one `oats: warning`. What a read observes is
|
|
19
|
+
unchanged, and an apply still observes every member live. Known limit: the
|
|
20
|
+
4 MiB bounds what OATS keeps, not what Git downloads, so a remote with a
|
|
21
|
+
larger ref list transfers it once before OATS switches to protocol v2.
|
|
22
|
+
- **oats.engineering 1.5.0** (catalog and workspace pin, and the bundled
|
|
23
|
+
mirrors): developers keep each dynamic workflow under 10 agents and ask
|
|
24
|
+
their human for permission first for more. Within the cap they run
|
|
25
|
+
workflows without asking where the harness allows it; where it requires the
|
|
26
|
+
human's opt-in (Claude Code's workflow tool does), they use a standing
|
|
27
|
+
opt-in the human configured, or ask once per task.
|
|
28
|
+
- **The `oats-setup-admin` soul has knowledge and is harvested.** It drops
|
|
29
|
+
`oats.developer` and `knowledge: none` and takes the workspace's oats.okf
|
|
30
|
+
knowledge slot, owning the new `oats/oats-setup-admin` node (the judgement
|
|
31
|
+
of administering a workspace's config, never deployment state) and reading
|
|
32
|
+
the operator, expert and kernel nodes. This reverses the 0.29.1 opt-out:
|
|
33
|
+
deployment specifics are kept out of the base at promotion, by the node's
|
|
34
|
+
charter and the reviewed harvest PR, instead of by not harvesting the soul.
|
|
35
|
+
It needs `oats/oats-setup-admin` in the bound `oats` base
|
|
36
|
+
(awebai/oats-knowledge#51); new instances only.
|
|
37
|
+
|
|
38
|
+
- **`PI_AGENT_INSTANCE` and `PI_AGENT_HOME` are gone.** No launch sets them,
|
|
39
|
+
for any harness, pi included. (A home that records only its launch command,
|
|
40
|
+
from before launch recipes, keeps running that command as recorded.) The identity is `OATS_INSTANCE` and
|
|
41
|
+
`OATS_INSTANCE_HOME`, and the pi bridge (`@awebai/oats-pi`) reads
|
|
42
|
+
`OATS_INSTANCE_HOME`.
|
|
43
|
+
- Upgrade the CLI and the pi bridge together (`oats update` does both). A
|
|
44
|
+
0.34.0 pi bridge under a 0.34.1 kernel does not find its instance home.
|
|
45
|
+
- Capability commands no longer read `PI_AGENT_HOME`. A leftover one in an
|
|
46
|
+
operator's shell no longer pins a command to that instance.
|
|
47
|
+
- The names stay reserved: a launch configuration still cannot set them.
|
|
48
|
+
|
|
49
|
+
## Fixed
|
|
50
|
+
|
|
51
|
+
- **A capability command inside an instance home says which home, and why.**
|
|
52
|
+
- A `--soul` naming another soul than the home's was silently ignored; it
|
|
53
|
+
is now refused with `E_HOME_MISMATCH`.
|
|
54
|
+
- A namespace the home does not have answered `unknown command`; it is now
|
|
55
|
+
`E_UNKNOWN_COMMAND` naming the home.
|
|
56
|
+
- Both name what chose the home: `OATS_INSTANCE_HOME`, `OATS_HOME` or the
|
|
57
|
+
working directory.
|
|
@@ -245,8 +245,7 @@ OATS codex session does not appear in `codex agents`. So that the environment
|
|
|
245
245
|
does not depend on this, a codex launch also sets it for tool
|
|
246
246
|
commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
|
|
247
247
|
|
|
248
|
-
- the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME
|
|
249
|
-
`PI_AGENT_HOME`;
|
|
248
|
+
- the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`;
|
|
250
249
|
- every capability's launch environment (for example the messaging
|
|
251
250
|
provider's identity home and delivery mode);
|
|
252
251
|
- the launch configuration's literal values. A reference's value never goes
|
|
@@ -258,9 +257,9 @@ runs tool commands through the user's login shell, and a profile that prepends
|
|
|
258
257
|
directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
|
|
259
258
|
directory is found first.
|
|
260
259
|
|
|
261
|
-
A capability command (`oats <namespace> …`)
|
|
262
|
-
`OATS_INSTANCE_HOME
|
|
263
|
-
from the working directory. It uses the nearest enclosing directory laid out as
|
|
260
|
+
A capability command (`oats <namespace> …`) runs in the instance home that
|
|
261
|
+
`OATS_INSTANCE_HOME` names, else `OATS_HOME`. With neither set, it finds its
|
|
262
|
+
instance from the working directory. It uses the nearest enclosing directory laid out as
|
|
264
263
|
`<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
|
|
265
264
|
name, and validates it like a home named by the environment. The walk uses the
|
|
266
265
|
directory as the shell names it (`$PWD`). That matters for an attached
|
|
@@ -268,6 +267,13 @@ instance, whose `work/` links into its owner's tree: below it, the physical
|
|
|
268
267
|
path is the owner's. A process that has no `$PWD` there would act as the
|
|
269
268
|
owner, so an attached instance runs capability commands from its home.
|
|
270
269
|
|
|
270
|
+
Inside an instance home the namespace is that home's. A `--soul` naming
|
|
271
|
+
another soul is refused (`E_HOME_MISMATCH`), and a namespace the home does
|
|
272
|
+
not have is `E_UNKNOWN_COMMAND`. Both name the home and what chose it (the
|
|
273
|
+
variable, or the working directory). To run a command as a spawn of another
|
|
274
|
+
soul would, run it from the deployment with `OATS_INSTANCE_HOME` and
|
|
275
|
+
`OATS_HOME` unset.
|
|
276
|
+
|
|
271
277
|
## Lifecycle
|
|
272
278
|
|
|
273
279
|
### Spawn
|
|
@@ -561,9 +567,10 @@ Every instance is told its own home as **`OATS_INSTANCE_HOME`** (absolute), and
|
|
|
561
567
|
instructions refer to it as `<instance-home>`. The two environments differ, so
|
|
562
568
|
they are stated separately:
|
|
563
569
|
|
|
564
|
-
- **Runtime session**: `OATS_INSTANCE_HOME` and `
|
|
565
|
-
|
|
566
|
-
|
|
570
|
+
- **Runtime session**: `OATS_INSTANCE_HOME` and `OATS_INSTANCE`, for every
|
|
571
|
+
harness. The pi extension reads `OATS_INSTANCE_HOME` too; the `PI_AGENT_*`
|
|
572
|
+
names are not set (they stay reserved, so a launch configuration cannot set
|
|
573
|
+
them).
|
|
567
574
|
- **Lifecycle hooks**: `OATS_INSTANCE_HOME` and `OATS_HOME`, alongside the rest of
|
|
568
575
|
the hook contract. `OATS_HOME` predates `OATS_INSTANCE_HOME` and is kept because
|
|
569
576
|
shipped capability hooks read it; it is **not** exported to harness sessions.
|
package/docs/workspaces.md
CHANGED
|
@@ -174,7 +174,18 @@ from *outside* that boundary, and **declaring one in the workspace's
|
|
|
174
174
|
access context.** The kernel reads both halves over the remotes
|
|
175
175
|
(`git ls-remote`, shallow fetches, the operator's own credential helpers,
|
|
176
176
|
never a prompt). A half that cannot be read makes the member *unconfirmed*,
|
|
177
|
-
never a half-success.
|
|
177
|
+
never a half-success. A remote's current head is read with Git's protocol v0
|
|
178
|
+
(one round trip) unless your Git configuration sets `protocol.version`, which
|
|
179
|
+
is then used as set; a tag or branch is read as before. A remote whose v0 ref
|
|
180
|
+
advertisement is over 4 MiB is read with protocol v2 from then on: the 4 MiB
|
|
181
|
+
bounds what OATS keeps, not the download, so the first read transfers that
|
|
182
|
+
advertisement once before falling back. A remote that times out under v0
|
|
183
|
+
fails as before and is read with protocol v2 for the next 7 days; a read
|
|
184
|
+
ended by the 12 s budget of `oats status` or `oats workspace status` is not
|
|
185
|
+
the remote's own timeout, and is neither remembered nor warned about. One that
|
|
186
|
+
fails under v0 for another reason than an authentication refusal, a missing
|
|
187
|
+
repository or the local cache is read again with v2. Each case prints one
|
|
188
|
+
`oats: warning`; the first two are remembered in the remote cache. `oats workspace status` and `oats sync` show each member
|
|
178
189
|
as `confirmed` or the reason it is not:
|
|
179
190
|
|
|
180
191
|
| status | meaning |
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
export const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
|
|
14
14
|
export const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
15
15
|
export const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
16
|
-
export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"
|
|
16
|
+
export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
|
|
17
17
|
export const PROCESS_BOOTSTRAP_ENV = new Set([
|
|
18
18
|
"PATH", "HOME", "SHELL", "TMPDIR", "TMP", "TEMP", "PWD", "OLDPWD", "SHLVL", "_",
|
|
19
19
|
"ENV", "BASH_ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "IFS", "PROMPT_COMMAND", "PS4", "ZDOTDIR",
|
package/lib/core.mjs
CHANGED
|
@@ -2354,7 +2354,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
|
|
|
2354
2354
|
// The launch's environment, in prefix order: the instance, the capabilities' env, the
|
|
2355
2355
|
// configuration's env (a reference by reference, never by value).
|
|
2356
2356
|
const hookEnv = recipe.hooks?.env || {};
|
|
2357
|
-
const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]
|
|
2357
|
+
const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]].map(([name, value]) => ({ name, value }));
|
|
2358
2358
|
for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
|
|
2359
2359
|
for (const name of Object.keys(recipe.env || {}).sort()) {
|
|
2360
2360
|
const v = recipe.env[name];
|
|
@@ -2561,7 +2561,7 @@ export function describeLaunchCommand(command) {
|
|
|
2561
2561
|
* stay references); a command the parser refuses is withheld whole. */
|
|
2562
2562
|
/** The identity environment every launch carries: public facts (instance
|
|
2563
2563
|
* name, home path), shown in public renderings; everything else is withheld. */
|
|
2564
|
-
export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"
|
|
2564
|
+
export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
|
|
2565
2565
|
export function redactLaunchCommand(command) {
|
|
2566
2566
|
try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
|
|
2567
2567
|
catch { return "<unparseable launch command withheld>"; }
|
|
@@ -2720,7 +2720,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2720
2720
|
// decision: an attached agent shares its owner's work tree and is ALWAYS the
|
|
2721
2721
|
// owner's child — relation flags that say anything else are contradictory and
|
|
2722
2722
|
// rejected. Ambient env
|
|
2723
|
-
// (OATS_INSTANCE
|
|
2723
|
+
// (OATS_INSTANCE) is deliberately NOT consulted: any shell
|
|
2724
2724
|
// opened inside an agent's tmux window inherits those vars, and env inheritance
|
|
2725
2725
|
// is not evidence of intent — human spawns from such shells were misattributed
|
|
2726
2726
|
// as instance-origin. Manual spawns land top-level unless a relation is
|
|
@@ -85,21 +85,43 @@ export function qualifiedSoulName(entry) {
|
|
|
85
85
|
return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** A soul name as an operator gives it: `[repoPart, soulPart]`, repoPart null for a bare name. */
|
|
89
|
+
function splitSoulName(name) {
|
|
90
|
+
return name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
|
|
91
|
+
}
|
|
92
|
+
/** Whether `name` names the member or package soul `{ name, repoKey }` / `{ name, package }`: a
|
|
93
|
+
* bare name, `<repo>/<soul>` with the member's key, a key suffix or its member name, or
|
|
94
|
+
* `<package>/<soul>` for a package soul. findSoulEntry's rule for each candidate. */
|
|
95
|
+
export function soulNameMatches(name, soul) {
|
|
96
|
+
const [repoPart, soulPart] = splitSoulName(name);
|
|
97
|
+
if (soulPart !== soul.name) return false;
|
|
98
|
+
if (!repoPart) return true;
|
|
99
|
+
if (typeof soul.package === "string") return repoPart === soul.package;
|
|
100
|
+
const key = soul.repoKey ?? "";
|
|
101
|
+
return key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
102
|
+
}
|
|
103
|
+
/** Whether `name` names the soul an instance home was spawned from (its instance.json `meta`): any name
|
|
104
|
+
* a spawn accepts for it, or the home's agent directory name. */
|
|
105
|
+
export function homeSoulMatches(name, meta) {
|
|
106
|
+
if (name === meta?.agent) return true;
|
|
107
|
+
const soul = meta?.workspace?.soul ?? {};
|
|
108
|
+
return soulNameMatches(name, soul.package && typeof soul.package === "object" ? { name: soul.name, package: soul.package.id } : { name: meta?.agent, repoKey: soul.repoKey });
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
/** Find the soul named `name` in a discovery: confirmed members, external souls and package
|
|
89
112
|
* souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
|
|
90
113
|
* qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
|
|
91
114
|
* or an external source, `<package>/<soul>` a package soul. */
|
|
92
115
|
export function findSoulEntry(discovery, name) {
|
|
93
|
-
const [repoPart, soulPart] =
|
|
116
|
+
const [repoPart, soulPart] = splitSoulName(name);
|
|
94
117
|
const hits = [];
|
|
95
118
|
// A standalone view's one row is the repo's own (unconfirmed by definition — the
|
|
96
119
|
// workspace could not be read); resolveSoul admits exactly that case.
|
|
97
120
|
const standaloneOwn = discovery.standalone === true ? discovery.key : null;
|
|
98
|
-
const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
99
121
|
for (const m of discovery.members || []) {
|
|
100
122
|
if (!m.confirmed && m.key !== standaloneOwn) continue;
|
|
101
123
|
for (const s of m.souls || []) {
|
|
102
|
-
if (s.name
|
|
124
|
+
if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
|
|
103
125
|
hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
|
|
104
126
|
}
|
|
105
127
|
}
|
|
@@ -107,7 +129,7 @@ export function findSoulEntry(discovery, name) {
|
|
|
107
129
|
if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
|
|
108
130
|
}
|
|
109
131
|
for (const s of discovery.packageSouls || []) {
|
|
110
|
-
if (
|
|
132
|
+
if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
|
|
111
133
|
}
|
|
112
134
|
if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
|
|
113
135
|
if (hits.length > 1) {
|
package/lib/remote.mjs
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* APPROACH (one approach, used for every remote kind — local bare repos and
|
|
5
5
|
* https/ssh remotes alike):
|
|
6
6
|
* 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
|
|
7
|
-
* never a fetch when the caller already gave a full OID.
|
|
7
|
+
* never a fetch when the caller already gave a full OID. A HEAD observation
|
|
8
|
+
* speaks protocol v0 unless the operator pinned `protocol.version` (one round
|
|
9
|
+
* trip instead of v2's two; what is kept of the advertisement is bounded, and a
|
|
10
|
+
* remote over budget, or one that timed out under v0, is observed under v2:
|
|
11
|
+
* observeLive).
|
|
8
12
|
* 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
|
|
9
13
|
* commit locally. `ensureCommit` does a shallow, partial
|
|
10
14
|
* `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
|
|
@@ -168,6 +172,10 @@ export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
|
168
172
|
/** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
|
|
169
173
|
* this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
|
|
170
174
|
export const READ_REMOTE_BUDGET_MS = 12_000;
|
|
175
|
+
/** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
|
|
176
|
+
* reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
|
|
177
|
+
* and the remote observed under v2, its server-side filter, from then on (observeLive). */
|
|
178
|
+
export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
|
|
171
179
|
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
172
180
|
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
173
181
|
/** `--max-age` bounds (seconds). */
|
|
@@ -752,7 +760,7 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
|
752
760
|
|
|
753
761
|
class ReadSession {
|
|
754
762
|
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
755
|
-
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
|
|
763
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
|
|
756
764
|
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
757
765
|
if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
|
|
758
766
|
this.maxAge = maxAge;
|
|
@@ -764,6 +772,8 @@ class ReadSession {
|
|
|
764
772
|
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
765
773
|
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
766
774
|
this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
|
|
775
|
+
this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
|
|
776
|
+
this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
|
|
767
777
|
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
768
778
|
this.observations = new Map(); // memo key → Promise<head observation>
|
|
769
779
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
@@ -853,7 +863,8 @@ class ReadSession {
|
|
|
853
863
|
|
|
854
864
|
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
|
|
855
865
|
* milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
|
|
856
|
-
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs
|
|
866
|
+
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
|
|
867
|
+
* `v0AdvertisementBudget`. */
|
|
857
868
|
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
858
869
|
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
859
870
|
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
@@ -1390,7 +1401,7 @@ function parseLsRemote(stdout) {
|
|
|
1390
1401
|
}
|
|
1391
1402
|
|
|
1392
1403
|
function resolveAt(parsed, at) {
|
|
1393
|
-
if (at
|
|
1404
|
+
if (isHeadAt(at)) {
|
|
1394
1405
|
const oid = parsed.oids.get("HEAD");
|
|
1395
1406
|
return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
|
|
1396
1407
|
}
|
|
@@ -1434,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
|
|
|
1434
1445
|
|
|
1435
1446
|
/** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
|
|
1436
1447
|
function lsRemoteArgs(ref, at) {
|
|
1437
|
-
const wantHead = at
|
|
1448
|
+
const wantHead = isHeadAt(at);
|
|
1438
1449
|
if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
|
|
1439
1450
|
const args = ["ls-remote", "--symref", ref.url];
|
|
1440
1451
|
if (wantHead) args.push("HEAD");
|
|
@@ -1518,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
|
|
|
1518
1529
|
} finally { session.observeDone(); }
|
|
1519
1530
|
}
|
|
1520
1531
|
|
|
1532
|
+
/** Whether `at` asks for the remote's default branch (a HEAD observation). */
|
|
1533
|
+
const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
|
|
1534
|
+
|
|
1535
|
+
/** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
|
|
1536
|
+
* `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
|
|
1537
|
+
* once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
|
|
1538
|
+
* included) → true: today's argv, never an error. */
|
|
1539
|
+
const protocolPins = new WeakMap();
|
|
1540
|
+
function protocolPinned(exec, session) {
|
|
1541
|
+
const memo = session?.protocolPins ?? protocolPins;
|
|
1542
|
+
let pinned = memo.get(exec);
|
|
1543
|
+
if (!pinned) {
|
|
1544
|
+
pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
|
|
1545
|
+
.then(() => true, (error) => error?.code !== 1);
|
|
1546
|
+
memo.set(exec, pinned);
|
|
1547
|
+
}
|
|
1548
|
+
return pinned;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
|
|
1552
|
+
* beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
|
|
1553
|
+
* root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
|
|
1554
|
+
* good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
|
|
1555
|
+
* file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
|
|
1556
|
+
* Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
|
|
1557
|
+
* that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
|
|
1558
|
+
const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
|
|
1559
|
+
const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
|
|
1560
|
+
function lsRemoteV2Recorded(root, ref, now) {
|
|
1561
|
+
const record = (reason) => {
|
|
1562
|
+
const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
|
|
1563
|
+
return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
|
|
1564
|
+
};
|
|
1565
|
+
if (record("overflow")) return true;
|
|
1566
|
+
const rec = record("timeout");
|
|
1567
|
+
const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
|
|
1568
|
+
return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
|
|
1569
|
+
}
|
|
1570
|
+
function recordLsRemoteV2(root, ref, reason, now) {
|
|
1571
|
+
writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
|
|
1575
|
+
* such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
|
|
1576
|
+
const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
|
|
1580
|
+
* operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
|
|
1581
|
+
* advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
|
|
1582
|
+
* filtered answer gives, HEAD's symref included (v0's symref capability).
|
|
1583
|
+
* - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
|
|
1584
|
+
* advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
|
|
1585
|
+
* recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
|
|
1586
|
+
* - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
|
|
1587
|
+
* - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
|
|
1588
|
+
* and said once if the retry succeeds.
|
|
1589
|
+
* `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
|
|
1590
|
+
* and branches always use it.
|
|
1591
|
+
*/
|
|
1521
1592
|
async function observeLive(ref, args, at, options) {
|
|
1522
1593
|
const exec = options.exec ?? runGit;
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1594
|
+
const session = sessionOf(options);
|
|
1595
|
+
const signal = session?.signal;
|
|
1596
|
+
// Every git call takes what is left of the session's deadline, if it has one (sessionExec).
|
|
1597
|
+
const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
|
|
1598
|
+
const root = cacheRootOf(options);
|
|
1599
|
+
const now = () => (session ? session.now() : Date.now());
|
|
1600
|
+
const url = redactUrl(ref.url);
|
|
1601
|
+
const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
|
|
1602
|
+
let out = null, retried = null;
|
|
1603
|
+
const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
|
|
1604
|
+
// A command given up while its protocol was asked starts no ls-remote.
|
|
1605
|
+
if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
|
|
1606
|
+
if (v0) {
|
|
1607
|
+
const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
|
|
1608
|
+
// A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
|
|
1609
|
+
// which says nothing about the remote: its timeout is not recorded.
|
|
1610
|
+
const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
|
|
1611
|
+
try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
|
|
1612
|
+
catch (error) {
|
|
1613
|
+
if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
1614
|
+
recordLsRemoteV2(root, ref, "overflow", now());
|
|
1615
|
+
say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
|
|
1616
|
+
} else {
|
|
1617
|
+
const reason = classifyRemoteFailure(error);
|
|
1618
|
+
const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
|
|
1619
|
+
if (!aborted && reason === "timeout" && !cut) {
|
|
1620
|
+
recordLsRemoteV2(root, ref, "timeout", now());
|
|
1621
|
+
say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
|
|
1622
|
+
}
|
|
1623
|
+
if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
|
|
1624
|
+
retried = reason;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (!out) {
|
|
1629
|
+
try { out = await run(args); }
|
|
1630
|
+
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
1631
|
+
}
|
|
1527
1632
|
const parsed = parseLsRemote(out.stdout);
|
|
1528
1633
|
const hit = resolveAt(parsed, at);
|
|
1529
1634
|
if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1530
1635
|
if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1531
1636
|
const { commit } = await ensureCommit(ref, hit.commit, options);
|
|
1637
|
+
if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
|
|
1532
1638
|
return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
|
|
1533
1639
|
}
|
|
1640
|
+
const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
1534
1641
|
|
|
1535
1642
|
// ---------------------------------------------------------------------------
|
|
1536
1643
|
// tree reading
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.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",
|