@awebai/oats 0.40.1 → 0.41.0

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/lib/dir-lock.mjs CHANGED
@@ -10,9 +10,9 @@ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) {
10
10
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
11
 
12
12
  /** A mkdir lock that is never reclaimed by another process: an existing
13
- * lock whose owner is unreadable or dead is refused with the directory to
14
- * remove, because the gap between mkdir and owner.json belongs to a live
15
- * acquirer and a dead-owner reclaim races every other acquirer. The
13
+ * lock whose owner is unreadable or dead is refused after bounded waiting,
14
+ * with the directory to remove, because the gap between mkdir and owner.json
15
+ * belongs to a live acquirer and a dead-owner reclaim races every other acquirer. The
16
16
  * holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
17
17
  * held after `retryMs` is refused with `busy(why)`, the caller's own error. */
18
18
  export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
@@ -23,7 +23,10 @@ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
23
23
  catch (e) {
24
24
  if (e.code !== "EEXIST") throw e;
25
25
  let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
26
- if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
26
+ // Missing/unreadable owners also occur between mkdir and publication,
27
+ // and while the holder removes its directory. Wait without touching it.
28
+ const remaining = deadline - Date.now();
29
+ if (remaining > 0) { pause(Math.min(50, remaining)); continue; }
27
30
  throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
28
31
  }
29
32
  }
@@ -26,9 +26,20 @@
26
26
  * Producers write claims as `waiting` rows through `setWaiting` (the CLI's
27
27
  * `oats instance waiting` and `oats instance attention`): data
28
28
  * `{waitingOnYou, reason?, message?}`, appended only on a change. A claim is
29
- * evidence for display, never authority: nothing in the kernel acts on it. */
30
- import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
31
- import { basename, dirname, join } from "node:path";
29
+ * evidence for display, never authority: nothing in the kernel acts on it.
30
+ *
31
+ * ONE ADDRESS PER HOME (awebai/oats#583). A home has several spellings when its
32
+ * deployment is reached through a symlink or its agents root is one: status
33
+ * addresses it lexically, a session carries the real path. Rows are keyed by
34
+ * the home string, so every exported function here resolves the home it is
35
+ * given to its REAL path first (`addressOf`): rows record it, readers compare
36
+ * against it, and both logs are found from any spelling. `admit` stays strict.
37
+ * That is storage and matching. An ANSWER (readEvents, setWaiting) names the
38
+ * home the way the caller did: `admit` has proved every returned row is this
39
+ * one home's, so its `home` is the address that was asked about, and a consumer
40
+ * that checks "the rows' home is the home I asked for" keeps holding. */
41
+ import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
42
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
32
43
 
33
44
  export const EVENTS_API = 2;
34
45
  const HOME_LOG = ".oats-events.jsonl";
@@ -61,36 +72,104 @@ export function validWaitingMessage(m) {
61
72
  // closed set; anything else reads as null, and the claim still counts.
62
73
  /** A stored reason outside the closed set (a hand-edited or foreign row) reads as null. */
63
74
  const readReason = (r) => (WAITING_REASONS.includes(r) ? r : null);
75
+ /** An event time: a string that parses as a date. The writer refuses anything else. */
76
+ const validTime = (at) => typeof at === "string" && Number.isFinite(Date.parse(at));
64
77
 
65
- function workspaceLogPath(home) {
66
- // <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
67
- // state beside installed capabilities and schedules; survives the home's removal.
68
- const agentDir = dirname(dirname(home)); // <root>/<agent>/instances/<instance>
69
- const workspace = dirname(dirname(agentDir)); // <workspace>/agents/<agent>
70
- return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
78
+ /** The READ rule for one claim `{since, producer, reason, message}`, the same for a
79
+ * row of this machine's log and for a claim another kernel reports (a remote roster
80
+ * row): `since` is a valid date and `producer` is `kernel` or a producer id, or there
81
+ * is no claim (null); a reason outside the closed set and a message that fails
82
+ * validWaitingMessage read as null, and the claim still counts. Anything that is not
83
+ * such an object is null. */
84
+ export function readWaitingClaim(claim) {
85
+ if (!claim || typeof claim !== "object" || Array.isArray(claim)) return null;
86
+ const { since, producer } = claim;
87
+ if (!validTime(since)) return null;
88
+ // A producer the writer would refuse (a hand-edited log) is never shown as one.
89
+ if (producer !== "kernel" && !(typeof producer === "string" && WAITING_PRODUCER_RE.test(producer))) return null;
90
+ return { since, producer, reason: readReason(claim.reason), message: validWaitingMessage(claim.message) ? claim.message : null };
71
91
  }
72
92
 
73
- /** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
74
- export function incarnationOf(home) {
75
- try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; }
93
+ /** realpath of `p`, or, when `p` is gone, the realpath of its nearest existing
94
+ * ancestor with the rest re-appended (retirement writes its rows after the home
95
+ * is removed). */
96
+ function realOrNearest(p) {
97
+ try { return realpathSync(p); } catch { /* absent: resolve what exists */ }
98
+ let d = resolve(p); const tail = [];
99
+ while (!existsSync(d) && dirname(d) !== d) { tail.unshift(basename(d)); d = dirname(d); }
100
+ try { return join(realpathSync(d), ...tail); } catch { return resolve(p); }
101
+ }
102
+ function metaOf(home) {
103
+ try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return m && typeof m === "object" ? m : null; } catch { return null; }
104
+ }
105
+ const incarnationIn = (meta) => (typeof meta?.createdAt === "string" ? meta.createdAt : null);
106
+
107
+ /** <deployment>/.agents/events/<agent>--<instance>.jsonl: deployment-private state
108
+ * beside installed capabilities and schedules; survives the home's removal.
109
+ *
110
+ * The DEPLOYMENT decides where it is, not the real home string: under a symlinked
111
+ * agents root the real home's ancestors are outside the deployment. The rule:
112
+ * 1. the deployment the spawn recorded (instance.json `workspace.deployment`), used
113
+ * ONLY when it verifies: the real path of its agents/<agent>/instances/<instance>
114
+ * is this home, with agent and instance taken from the home's path, never from
115
+ * instance.json;
116
+ * 2. otherwise the fourth ancestor of the home AS THE CALLER SPELLED IT, which is how
117
+ * the kernel addresses a home with no record, or one already removed (retirement).
118
+ * The result is resolved, so every spelling of the home gives one path.
119
+ *
120
+ * Two limits. A home with no recorded deployment (or already removed) that is named
121
+ * by its REAL path under a symlinked agents root still derives a directory outside
122
+ * the deployment; no kernel caller does that. And instance.json is home content: a
123
+ * record edited to name another directory that links back to this home moves the log
124
+ * there. That is the class the home log is already in (a path the agent can write,
125
+ * which the kernel appends to under the same uid), so nothing stricter is checked. */
126
+ function workspaceLogPath(given, home, meta) {
127
+ const instance = basename(home), agent = basename(dirname(dirname(home))); // <root>/<agent>/instances/<instance>
128
+ const recorded = meta?.workspace?.deployment;
129
+ const verified = typeof recorded === "string" && isAbsolute(recorded) && realOrNearest(join(recorded, "agents", agent, "instances", instance)) === home;
130
+ const deployment = verified ? recorded : dirname(dirname(dirname(dirname(resolve(given)))));
131
+ return join(realOrNearest(deployment), ".agents", "events", `${agent}--${instance}.jsonl`);
76
132
  }
77
133
 
134
+ /** The one address of the home a caller named, in any spelling: the real home, its
135
+ * instance name and incarnation, its two logs, and `asked`, the caller's spelling. */
136
+ function addressOf(given) {
137
+ const home = realOrNearest(given);
138
+ const meta = metaOf(home);
139
+ let workspaceLog;
140
+ return {
141
+ home, asked: given, instance: basename(home), incarnation: incarnationIn(meta), homeLog: join(home, HOME_LOG),
142
+ get workspaceLog() { return (workspaceLog ??= workspaceLogPath(given, home, meta)); },
143
+ };
144
+ }
145
+
146
+ /** THE OUTPUT EDGE, and the only one: canonical inside, the caller's spelling outside.
147
+ * Whatever this module ANSWERS about an address (an events read, each row in it, a
148
+ * waiting answer) passes through here, which names the home as the caller spelled
149
+ * it. Rows on disk and every comparison keep the real path. A new answer that
150
+ * carries a `home` must go through this too, never write `a.home` out itself. */
151
+ const answered = (a, body) => ({ ...body, home: a.asked });
152
+
153
+ /** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
154
+ export function incarnationOf(home) { return incarnationIn(metaOf(home)); }
155
+
78
156
  /** Append one typed event. Never throws into the caller's action: an event is
79
157
  * evidence, not authority — a failed write is reported in the return value. */
80
- export function appendEvent(home, event, { workspaceOnly = false, incarnation, at } = {}) {
158
+ export function appendEvent(home, event, options) { return append(addressOf(home), event, options); }
159
+ function append(a, event, { workspaceOnly = false, incarnation, at } = {}) {
81
160
  if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
82
161
  // `at`: the time the fact became true, when it is recorded later (a start
83
162
  // boundary reconciled from its receipt); readers order rows by it.
84
- if (at !== undefined && (typeof at !== "string" || !Number.isFinite(Date.parse(at)))) return { ok: false, reason: `invalid event time ${at}` };
85
- const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
163
+ if (at !== undefined && !validTime(at)) return { ok: false, reason: `invalid event time ${at}` };
164
+ const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance: a.instance, home: a.home, incarnation: incarnation === undefined ? a.incarnation : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
86
165
  const line = JSON.stringify(row) + "\n";
87
166
  const results = [];
88
167
  // Retirement fingerprints the home against its baseline and preserves any
89
168
  // changed bytes as "unknown work": events written DURING retirement go only
90
169
  // to the workspace log, never into the home being inspected.
91
- for (const path of [...(workspaceOnly ? [] : [join(home, HOME_LOG)]), workspaceLogPath(home)]) {
170
+ for (const path of [...(workspaceOnly ? [] : [a.homeLog]), a.workspaceLog]) {
92
171
  try {
93
- if (path.startsWith(home) && !existsSync(home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
172
+ if (path === a.homeLog && !existsSync(a.home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
94
173
  mkdirSync(dirname(path), { recursive: true });
95
174
  appendFileSync(path, line);
96
175
  results.push({ path, ok: true });
@@ -174,11 +253,12 @@ function claimsOf(rows, incarnation) {
174
253
  for (const r of current.slice(boundary + 1)) {
175
254
  if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
176
255
  const p = r.producer ?? "kernel";
177
- // A producer the writer would refuse (a hand-edited log) is never shown as one.
178
- if (p !== "kernel" && !(typeof p === "string" && WAITING_PRODUCER_RE.test(p))) continue;
256
+ // A row the read rule refuses (a hand-edited log: its producer, its time) says nothing.
257
+ const claim = readWaitingClaim({ since: r.at, producer: p, reason: r.data.reason, message: r.data.message });
258
+ if (!claim) continue;
179
259
  claims.set(p, r.data.waitingOnYou
180
- ? { producer: p, waiting: true, since: r.at, reason: readReason(r.data.reason), message: validWaitingMessage(r.data.message) ? r.data.message : null }
181
- : { producer: p, waiting: false, since: r.at, reason: null, message: null });
260
+ ? { producer: p, waiting: true, since: claim.since, reason: claim.reason, message: claim.message }
261
+ : { producer: p, waiting: false, since: claim.since, reason: null, message: null });
182
262
  }
183
263
  return claims;
184
264
  }
@@ -186,19 +266,20 @@ const positiveOf = (claims) => [...claims.values()].filter((c) => c.waiting).sor
186
266
  const waitingShape = (c) => (c ? { since: c.since, producer: c.producer, reason: c.reason, message: c.message } : null);
187
267
 
188
268
  /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
189
- * window; integrity is reported independently of the selected rows. */
190
- export function readEvents(home, { limit = 200, since = null } = {}) {
191
- const instance = basename(home);
192
- const incarnation = incarnationOf(home);
193
- const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
269
+ * window; integrity is reported independently of the selected rows. The answer's
270
+ * `home`, and each returned row's, is the home as the caller spelled it. */
271
+ export function readEvents(given, { limit = 200, since = null } = {}) {
272
+ const address = addressOf(given);
273
+ const { home, instance, incarnation, homeLog, workspaceLog } = address;
274
+ const a = readLog(homeLog, "home"), b = readLog(workspaceLog, "workspace");
194
275
  const { rows, foreign } = admit(home, [a, b]);
195
276
  const claims = claimsOf(rows, incarnation);
196
277
  const waitingClaims = [...claims.values()];
197
278
  const positive = positiveOf(claims);
198
279
  const filtered = since ? rows.filter((r) => r.at > since) : rows;
199
- const window = filtered.slice(-limit);
280
+ const window = filtered.slice(-limit).map((r) => answered(address, r));
200
281
  const last = window.at(-1) ?? null;
201
- return {
282
+ return answered(address, {
202
283
  eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
203
284
  truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
204
285
  integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
@@ -212,7 +293,7 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
212
293
  "a claim older than the incarnation's latest kernel launched, restarted or stopped row belongs to an ended session and is not counted",
213
294
  "integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
214
295
  ],
215
- };
296
+ });
216
297
  }
217
298
 
218
299
  /** The instance's live waiting state, `{since, producer, reason, message}` or
@@ -221,11 +302,11 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
221
302
  * (the workspace log only when the home log is absent): every row a producer
222
303
  * or a session boundary writes goes to both. */
223
304
  export function liveWaiting(home) {
224
- const incarnation = incarnationOf(home);
225
- if (incarnation === null) return null;
226
- let log = readLog(join(home, HOME_LOG), "home");
227
- if (log.source.status === "absent") log = readLog(workspaceLogPath(home), "workspace");
228
- return waitingShape(positiveOf(claimsOf(admit(home, [log]).rows, incarnation)));
305
+ const a = addressOf(home);
306
+ if (a.incarnation === null) return null;
307
+ let log = readLog(a.homeLog, "home");
308
+ if (log.source.status === "absent") log = readLog(a.workspaceLog, "workspace");
309
+ return waitingShape(positiveOf(claimsOf(admit(a.home, [log]).rows, a.incarnation)));
229
310
  }
230
311
 
231
312
  const waitingError = (code, message) => Object.assign(new Error(message), { code });
@@ -238,8 +319,9 @@ const waitingError = (code, message) => Object.assign(new Error(message), { code
238
319
  * other log; success means both logs took the row. Validates its input
239
320
  * (E_BAD_ARGS); a home without a readable instance.json is
240
321
  * E_SESSION_UNKNOWN; a write either log refused is E_EVENTS_FAILED (a retry
241
- * repairs it). Evidence, not authority: nothing else is touched. */
242
- export function setWaiting(home, { producer, waiting, reason, message } = {}) {
322
+ * repairs it). The answer's `home` is the home as the caller spelled it; the row
323
+ * records the real path. Evidence, not authority: nothing else is touched. */
324
+ export function setWaiting(given, { producer, waiting, reason, message } = {}) {
243
325
  if (typeof producer !== "string" || !WAITING_PRODUCER_RE.test(producer)) throw waitingError("E_BAD_ARGS", `--producer must match ${WAITING_PRODUCER_RE.source}`);
244
326
  if (producer === "kernel") throw waitingError("E_BAD_ARGS", "--producer kernel is reserved for the kernel's own events");
245
327
  if (typeof waiting !== "boolean") throw waitingError("E_BAD_ARGS", "waiting must be set or clear");
@@ -250,15 +332,16 @@ export function setWaiting(home, { producer, waiting, reason, message } = {}) {
250
332
  if (reason !== undefined) throw waitingError("E_BAD_ARGS", "--reason is for set, not clear");
251
333
  if (message !== undefined) throw waitingError("E_BAD_ARGS", "--message is for set, not clear");
252
334
  }
253
- const incarnation = incarnationOf(home);
254
- if (incarnation === null) throw waitingError("E_SESSION_UNKNOWN", `${home} is not an instance home (no readable instance.json)`);
255
- const live = [readLog(join(home, HOME_LOG), "home"), readLog(workspaceLogPath(home), "workspace")]
335
+ const a = addressOf(given);
336
+ const { home, incarnation } = a;
337
+ if (incarnation === null) throw waitingError("E_SESSION_UNKNOWN", `${given} is not an instance home (no readable instance.json)`);
338
+ const live = [readLog(a.homeLog, "home"), readLog(a.workspaceLog, "workspace")]
256
339
  .map((log) => { const c = claimsOf(admit(home, [log]).rows, incarnation).get(producer); return c?.waiting ? c : null; });
257
340
  const agrees = (c) => (waiting ? !!c && c.reason === reason && c.message === (message ?? null) : !c);
258
- const answer = (changed, claim) => ({ eventsApi: EVENTS_API, instance: basename(home), home, producer, changed, waitingOnYou: waitingShape(claim) });
341
+ const answer = (changed, claim) => answered(a, { eventsApi: EVENTS_API, instance: a.instance, home, producer, changed, waitingOnYou: waitingShape(claim) });
259
342
  if (live.every(agrees)) return answer(false, waiting ? live[0] : null);
260
343
  const data = waiting ? { waitingOnYou: true, reason, ...(message !== undefined ? { message } : {}) } : { waitingOnYou: false };
261
- const res = appendEvent(home, { producer, kind: "waiting", data }, { incarnation });
344
+ const res = append(a, { producer, kind: "waiting", data }, { incarnation });
262
345
  const failed = (res.results || []).filter((r) => !r.ok);
263
346
  if (!res.ok || failed.length) throw waitingError("E_EVENTS_FAILED", `could not record the waiting claim in ${failed.map((r) => `${r.path} (${r.reason})`).join(", ") || res.reason}; retry to complete it`);
264
347
  return answer(true, waiting ? { producer, since: res.row.at, reason, message: message ?? null } : null);
@@ -272,22 +355,23 @@ export function setWaiting(home, { producer, waiting, reason, message } = {}) {
272
355
  * same data), and only when neither has it is the row made, still at the
273
356
  * launch time, so a claim the new session made since is kept. `ok` is true
274
357
  * only when every log holds the row; evidence, never authority. */
275
- export function recordStartBoundary(home, { startId, startedAt, ...data }) {
276
- const paths = [join(home, HOME_LOG), workspaceLogPath(home)];
277
- const instance = basename(home);
358
+ export function recordStartBoundary(given, { startId, startedAt, ...data }) {
359
+ const a = addressOf(given);
360
+ const { home, instance } = a;
361
+ const paths = [a.homeLog, a.workspaceLog];
278
362
  const isIt = (r) => r.instance === instance && r.home === home && (r.producer ?? "kernel") === "kernel" && r.kind === "launched" && r.data?.startId === startId;
279
363
  const found = paths.map((path) => readLog(path, "log").rows.find(isIt) ?? null);
280
364
  if (found.every(Boolean)) return { ok: true, existed: true };
281
365
  const existing = found.find(Boolean);
282
366
  if (!existing) {
283
- const res = appendEvent(home, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
367
+ const res = append(a, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
284
368
  return { ...res, ok: res.ok && (res.results || []).every((r) => r.ok) };
285
369
  }
286
370
  // The row exactly as the other log holds it: same time, incarnation and data.
287
371
  const line = JSON.stringify(existing) + "\n";
288
372
  const results = paths.filter((_, i) => !found[i]).map((path) => {
289
373
  try {
290
- if (path.startsWith(home) && !existsSync(home)) return { path, ok: false, reason: "home absent" };
374
+ if (path === a.homeLog && !existsSync(home)) return { path, ok: false, reason: "home absent" };
291
375
  mkdirSync(dirname(path), { recursive: true });
292
376
  appendFileSync(path, line);
293
377
  return { path, ok: true };
@@ -6,7 +6,7 @@
6
6
  * repository's default branch) with "no upstream" ≠ 0/0. Diffs are bounded and
7
7
  * addressed by an opaque file id minted with an observation revision; a diff
8
8
  * against a tree that has since moved is refused, never served. */
9
- import { execFileSync } from "node:child_process";
9
+ import { execFileSync, spawnSync } from "node:child_process";
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
@@ -44,6 +44,78 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
44
44
  }
45
45
  const trim = (s) => (s === null ? null : s.trim());
46
46
 
47
+ const BRANCH_REFS = Buffer.from("refs/heads/");
48
+ /** The two reads of HEAD, as bytes. They run like the module's other reads:
49
+ * read-only, helper-free, and with the module's own environment (gitEnv),
50
+ * which keeps only the caller's PATH and HOME: GIT_DIR, GIT_WORK_TREE,
51
+ * GIT_COMMON_DIR, GIT_INDEX_FILE and every other variable of the caller are
52
+ * not passed, and no global or system configuration is read, so nothing but
53
+ * the work tree named by `-C` decides what is read. `-C` comes first. */
54
+ const readHead = (work, args) => spawnSync("git", ["-C", work, ...READ_ONLY_GIT, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
55
+ /** What a failed read of HEAD says, without a line of the output that was not an error. */
56
+ const headReadFailure = (r, command) => String(r.error?.message ?? r.stderr ?? "").trim() || `git ${command} ${r.signal ? `was ended by ${r.signal}` : `exited with ${r.status}`}`;
57
+
58
+ /** What a work tree's HEAD names → { branch, detached, ref }. The one reader
59
+ * of that name: the retire plan and the retire both use it.
60
+ *
61
+ * `ref` is the ref HEAD points at, as the bytes `git symbolic-ref --quiet HEAD`
62
+ * prints, without the one line feed that ends them; null when HEAD is
63
+ * detached. It is evidence for a comparison and is never serialized. Through a
64
+ * chain of symbolic refs it is the last one: the branch the commits go to.
65
+ *
66
+ * `branch` is the name OATS carries for it: the part after `refs/heads/`, when
67
+ * that part is valid UTF-8 (it decodes and encodes back to the same bytes).
68
+ * Nothing of it is trimmed or replaced: a name may begin or end with a
69
+ * character that reads as white space. It is null when HEAD is detached, and
70
+ * also for a ref OATS carries no name for: one whose name is not valid UTF-8,
71
+ * or one outside `refs/heads/`. So `branch: null, detached: false` means "a
72
+ * name OATS does not carry", and two such HEADs are never taken for the same
73
+ * one by their missing names: compare `ref`.
74
+ *
75
+ * A read that fails throws E_WORK_INSPECTION_FAILED. Only Git's own quiet
76
+ * answer (exit 1, nothing printed) is "detached". */
77
+ export function headName(work) {
78
+ const r = readHead(work, ["symbolic-ref", "--quiet", "HEAD"]);
79
+ if (!r.error && r.status === 0 && r.stdout.length > 1) {
80
+ const ref = Buffer.from(r.stdout[r.stdout.length - 1] === 0x0a ? r.stdout.subarray(0, -1) : r.stdout);
81
+ let branch = null;
82
+ if (ref.length > BRANCH_REFS.length && ref.subarray(0, BRANCH_REFS.length).equals(BRANCH_REFS)) {
83
+ const name = ref.subarray(BRANCH_REFS.length);
84
+ try {
85
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(name);
86
+ if (Buffer.from(text, "utf8").equals(name)) branch = text;
87
+ } catch { /* not valid UTF-8: a name OATS does not carry */ }
88
+ }
89
+ return { branch, detached: false, ref };
90
+ }
91
+ if (!r.error && r.status === 1 && !r.stdout.length && !r.stderr.length) return { branch: null, detached: true, ref: null };
92
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read what the worktree at ${work} has checked out: ${headReadFailure(r, "symbolic-ref")}`);
93
+ }
94
+
95
+ /** A worktree's HEAD for a retire → { commit, branch, detached, ref }: what
96
+ * headName gives, and the commit HEAD is at.
97
+ *
98
+ * A HEAD that has no commit (a branch that is not born yet) is a failed read
99
+ * here: the retire proves its copy against this commit, and removes a
100
+ * worktree only after it has asked which refs reach it, so there is nothing
101
+ * it could do without one. That differs from the plan on purpose: the plan
102
+ * (observeInstanceGit) reads the commit for itself, and an unborn work tree is
103
+ * a value on its wire (`revision: "unborn"`).
104
+ *
105
+ * The name and the commit are two reads, and nothing binds them but what is
106
+ * done with them: a copy is proven against `commit`, so a name read at
107
+ * another moment than the commit gives a copy that fails its proof. Nothing
108
+ * destructive rests on the name. */
109
+ export function worktreeHead(work) {
110
+ const name = headName(work);
111
+ const r = readHead(work, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
112
+ const commit = !r.error && r.status === 0 ? r.stdout.toString("latin1").replace(/\n$/, "") : "";
113
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
114
+ throw oatsError("E_WORK_INSPECTION_FAILED", `the HEAD of the worktree at ${work} has no commit that could be read${r.error || r.stderr?.length ? `: ${headReadFailure(r, "rev-parse")}` : ""}`);
115
+ }
116
+ return { commit, ...name };
117
+ }
118
+
47
119
  /** The instance's work tree, from its home. The home's instance.json names
48
120
  * the work mode; the tree is `<home>/work` (a directory or a symlink to the
49
121
  * shared checkout). Missing/retired → attributed refusal, never a crash. */
@@ -169,12 +241,49 @@ function fileId(revision, indexOid, entry) {
169
241
  return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
170
242
  }
171
243
 
244
+ /** The HEAD of a worktree a retire is about to remove, and whether a ref of
245
+ * its repository reaches that commit → { head, unreached }. A ref outlives
246
+ * the worktree; HEAD, and refs only the worktree has, do not.
247
+ *
248
+ * One read in the repository that carries no ref name, so a ref whose name is
249
+ * not valid UTF-8, or very many refs, cannot make it refuse:
250
+ * `git for-each-ref --contains <commit> --count=1 --format=%(objectname)`.
251
+ * Exit 0 with a line printed: some ref reaches the commit. Exit 0 with
252
+ * nothing printed: none does, and the commit is something to preserve. Any
253
+ * other exit throws E_WORK_INSPECTION_FAILED. Standard error is not a failure
254
+ * here: a damaged ref prints a warning there with exit 0, and reaches
255
+ * nothing. A commit that only another worktree's HEAD reaches counts as not
256
+ * reached, which can only add a copy. */
257
+ export function worktreeCommitUnreached(repo, work) {
258
+ const head = worktreeHead(work);
259
+ const r = spawnSync("git", ["-C", repo, ...READ_ONLY_GIT, "for-each-ref", "--contains", head.commit, "--count=1", "--format=%(objectname)"], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
260
+ if (r.error || r.status !== 0) throw oatsError("E_WORK_INSPECTION_FAILED", `could not read which refs of ${repo} reach the commit the worktree at ${work} has checked out: ${headReadFailure(r, "for-each-ref")}`);
261
+ return { head, unreached: r.stdout.length === 0 };
262
+ }
263
+
264
+ /** The check made immediately before a retire removes a worktree: HEAD as it
265
+ * is now (`now`, worktreeHead) against HEAD as the final inspection read it
266
+ * (`inspected`). Equal when the commits are equal and both are detached or
267
+ * both point at a ref with the same bytes. The branch names are not compared:
268
+ * for a ref OATS carries no name for they are null on both sides, and null
269
+ * equal to null proves nothing. Anything else throws
270
+ * E_WORK_PRESERVATION_FAILED, and the worktree is not removed. */
271
+ export function assertSameWorktreeHead(inspected, now) {
272
+ const same = !!inspected && !!now && inspected.commit === now.commit
273
+ && (inspected.ref === null || now.ref === null ? inspected.ref === null && now.ref === null && inspected.detached === true && now.detached === true : Buffer.compare(inspected.ref, now.ref) === 0);
274
+ if (!same) throw oatsError("E_WORK_PRESERVATION_FAILED", "the worktree's HEAD changed after it was inspected, so the worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.");
275
+ }
276
+
172
277
  /** One consistent observation of the tree. Every field is what git said. */
173
278
  export function observeInstanceGit(home) {
174
279
  const { meta, work, mode } = instanceWorkTree(home);
175
280
  const headOid = trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true }));
176
281
  const raw = git(work, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--ignore-submodules=none"]);
177
282
  const { branch, entries } = parsePorcelainV2(raw);
283
+ // The name comes from headName, not from the status header, which prints
284
+ // "(detached)" and other words where a name would be and is read as text.
285
+ // The header still gives the upstream and its counts.
286
+ const head = headName(work);
178
287
  // The index state participates in the revision so that a stage/unstage
179
288
  // between observation and diff is a moved tree, not a stale-but-served diff.
180
289
  // Hashed from the index listing: no `write-tree`, so observing creates no object.
@@ -198,10 +307,10 @@ export function observeInstanceGit(home) {
198
307
  return {
199
308
  instanceGitApi: INSTANCE_GIT_API,
200
309
  instance: meta.instance ?? null, agent: meta.agent ?? null, home, workMode: mode,
201
- observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch.head, detached: branch.head === null && headOid !== null, unborn: headOid === null },
202
- recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch.head !== meta.branch },
310
+ observation: { revision, indexRevision: indexOid, at, worktree: work, branch: head.branch, detached: head.detached && headOid !== null, unborn: headOid === null },
311
+ recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && head.branch !== meta.branch },
203
312
  upstream, base: baseComparison,
204
- remote: remoteOf(work, branch.head),
313
+ remote: remoteOf(work, head.branch),
205
314
  summary, files,
206
315
  notes: [
207
316
  ...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
@@ -167,7 +167,8 @@ function writeFileSyncAtomic(path, value) {
167
167
  /** Facts for Remove (retire): what retirement would touch, with the design's
168
168
  * defaults (retain worktree and branch; never touch a PR). `oats retire`
169
169
  * itself applies them: plain retire re-homes the worktree; --discard-worktree
170
- * / --delete-branch are the dialog's opt-ins. */
170
+ * is the dialog's opt-in. No retire deletes a branch: `deleteBranch` is
171
+ * always false. */
171
172
  export function planRetire(ctx, root, name, { home } = {}) {
172
173
  const me = resolveInstance(ctx, root, name, { home });
173
174
  const kids = descendantsOf(me.root, name);
@@ -183,7 +184,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
183
184
  appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
184
185
  return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
185
186
  notes: [
186
- ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}; branch actions use the worktree's branch`] : []),
187
+ ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
187
188
  ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
188
189
  ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
189
190
  ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
package/lib/packages.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * OATS packages — versions, lock v3 (workspace model v2).
3
3
  *
4
4
  * Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
5
- * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-expert/decisions/workspace-model-v2.md.
5
+ * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md.
6
6
  *
7
7
  * A package is a place to fetch from WITH a version attached. Nothing is
8
8
  * installed: `resolvePackages` turns each `workspace.packages` entry into an
package/lib/resolve.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * lib/resolve.mjs — from a soul to an immutable resolution (module contract §3).
3
3
  *
4
4
  * Contract: docs/design/2026-09-23-workspace-module-contracts.md §3.
5
- * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-expert/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
5
+ * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
6
6
  *
7
7
  * `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
8
8
  * exact set of modules an instance will be built from: which capability comes
@@ -7,9 +7,10 @@ import { signalGroup } from "./process-group.mjs";
7
7
 
8
8
  const { file, args, timeout, graceMs, maxBuffer } = JSON.parse(readFileSync(0, "utf8"));
9
9
  const result = await new Promise((resolve) => {
10
- let child, status = null, signal = null, error;
11
- let exited = false, closed = false, stopping = false, hardEnd = false, settled = false;
10
+ let child, status = null, signal = null, error, interrupted = false;
11
+ let exited = false, closed = false, stopping = false, hardEnd = false, settled = false, termSent = false;
12
12
  let deadline, escalation, probe, groupEmpty = false;
13
+ const signalHandlers = new Map();
13
14
  const chunks = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 };
14
15
  const groupAlive = () => {
15
16
  if (groupEmpty || !child?.pid) return false;
@@ -21,6 +22,11 @@ const result = await new Promise((resolve) => {
21
22
  if (process.platform === "win32") { if (!exited) child.kill(sig); }
22
23
  else if (groupAlive()) signalGroup(child, sig);
23
24
  };
25
+ const requestTerm = () => {
26
+ if (termSent || !child?.pid) return;
27
+ termSent = true;
28
+ send("SIGTERM");
29
+ };
24
30
  const finish = () => {
25
31
  if (settled || (!closed && !hardEnd) || (!exited && child?.pid)) return;
26
32
  // A leader can close while a pipe-free descendant ignores TERM. Keep the
@@ -28,31 +34,55 @@ const result = await new Promise((resolve) => {
28
34
  if (stopping && !hardEnd && groupAlive()) return;
29
35
  settled = true;
30
36
  clearTimeout(deadline); clearTimeout(escalation); clearInterval(probe);
31
- resolve({ status, signal, stdout: Buffer.concat(chunks.stdout).toString("utf8"), stderr: Buffer.concat(chunks.stderr).toString("utf8"), ...(error ? { error } : {}) });
37
+ for (const [sig, handler] of signalHandlers) process.off(sig, handler);
38
+ resolve({ status, signal, stdout: Buffer.concat(chunks.stdout).toString("utf8"), stderr: Buffer.concat(chunks.stderr).toString("utf8"), ...(error ? { error } : {}), ...(interrupted ? { interrupted: true } : {}) });
39
+ };
40
+ const watchGroup = () => {
41
+ probe ??= setInterval(() => { groupAlive(); finish(); }, Math.min(50, graceMs));
32
42
  };
33
43
  const stop = (cause) => {
34
44
  if (stopping || settled) return;
35
- stopping = true; error = cause;
36
- send("SIGTERM");
45
+ stopping = true; error ??= cause;
46
+ requestTerm();
37
47
  escalation = setTimeout(() => {
38
48
  send("SIGKILL"); hardEnd = true;
39
49
  // A detached descendant can escape the group yet retain inherited pipes.
40
50
  // Its pipes must not prevent returning after the direct child's exit.
41
- child.stdout.destroy(); child.stderr.destroy();
51
+ child?.stdout?.destroy(); child?.stderr?.destroy();
42
52
  finish();
43
53
  }, graceMs);
44
- probe = setInterval(() => { if (closed) finish(); }, Math.min(50, graceMs));
54
+ watchGroup();
45
55
  };
56
+ // Catchable supervisor shutdown owns the same cleanup as timeout/overflow.
57
+ // Keep handlers throughout cleanup: repeated signals cannot bypass it.
58
+ // SIGKILL/OOM cannot run this path; a missing receipt stays unconfirmed.
59
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
60
+ const handler = () => {
61
+ // Preserve interruption even if overflow/timeout already began cleanup;
62
+ // the first error alone cannot describe both independent observations.
63
+ interrupted = true;
64
+ stop({ code: "EINTR", message: `schedule command supervisor interrupted by ${sig}` });
65
+ };
66
+ signalHandlers.set(sig, handler);
67
+ process.on(sig, handler);
68
+ }
46
69
  try { child = spawn(file, args, { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
47
- catch (e) { error = { code: e.code, message: e.message }; closed = true; finish(); return; }
70
+ catch (e) { error ??= { code: e.code, message: e.message }; closed = true; finish(); return; }
48
71
  for (const stream of ["stdout", "stderr"]) child[stream].on("data", (chunk) => {
49
72
  const room = maxBuffer - sizes[stream];
50
73
  if (room > 0) { const kept = chunk.subarray(0, room); chunks[stream].push(kept); sizes[stream] += kept.length; }
51
74
  if (chunk.length > room) stop({ code: "ENOBUFS", message: `schedule command ${stream} exceeded ${maxBuffer} bytes` });
52
75
  });
53
76
  child.once("error", (e) => { error ??= { code: e.code, message: e.message }; });
54
- child.once("exit", (code, sig) => { exited = true; status = code; signal = sig; finish(); });
77
+ child.once("exit", (code, sig) => {
78
+ exited = true; status = code; signal = sig;
79
+ // Do not wait for pipes or the deadline: an escaped session may hold pipes
80
+ // after this group empties. Once observed empty it is never signalled again.
81
+ if (groupAlive()) watchGroup();
82
+ finish();
83
+ });
55
84
  child.once("close", () => { closed = true; finish(); });
85
+ if (stopping) requestTerm(); // Shutdown may have begun while spawn returned.
56
86
  deadline = setTimeout(() => stop({ code: "ETIMEDOUT", message: `schedule command exceeded ${timeout} ms` }), timeout);
57
87
  });
58
88
  process.stdout.write(JSON.stringify(result));