@awebai/oats 0.39.4 → 0.40.2

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.
@@ -18,42 +18,158 @@
18
18
  * recreated same-address instance's earlier rows are visible as earlier;
19
19
  * - `waitingOnYou` is a producer STATE per (producer, current incarnation):
20
20
  * that producer's latest row carrying the field decides, an explicit `false`
21
- * clears, and it is computed over the full admitted read, before windowing. */
22
- import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
23
- import { basename, dirname, join } from "node:path";
21
+ * clears, and it is computed over the full admitted read, before windowing;
22
+ * - a claim is LIVE only after the incarnation's latest kernel session
23
+ * boundary (`launched` | `restarted` | `stopped`): a claim written before
24
+ * the boundary belongs to a session that has ended (WAITING_BOUNDARY_KINDS).
25
+ *
26
+ * Producers write claims as `waiting` rows through `setWaiting` (the CLI's
27
+ * `oats instance waiting` and `oats instance attention`): data
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
+ *
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";
24
43
 
25
44
  export const EVENTS_API = 2;
26
45
  const HOME_LOG = ".oats-events.jsonl";
27
46
  const MAX_BYTES = 4 * 1024 * 1024;
28
47
 
29
- export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed"];
48
+ export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed", "waiting"];
30
49
 
31
- function workspaceLogPath(home) {
32
- // <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
33
- // state beside installed capabilities and schedules; survives the home's removal.
34
- const agentDir = dirname(dirname(home)); // <root>/<agent>/instances/<instance>
35
- const workspace = dirname(dirname(agentDir)); // <workspace>/agents/<agent>
36
- return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
50
+ /** Kernel rows that end or begin a session: a waiting claim older than the
51
+ * incarnation's latest one is not live. */
52
+ export const WAITING_BOUNDARY_KINDS = ["launched", "restarted", "stopped"];
53
+ /** The closed set of reasons a positive claim carries. */
54
+ export const WAITING_REASONS = ["permission", "question", "attention"];
55
+ /** A producer id: what `--producer` accepts (`kernel` is reserved). */
56
+ export const WAITING_PRODUCER_RE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
57
+ export const WAITING_MESSAGE_MAX = 200;
58
+ /** Characters a claim's message may not contain (the maintainer's set): control
59
+ * characters (Cc: C0, DEL, C1), the Unicode line and paragraph separators, the bidi
60
+ * embeddings, overrides and isolates (U+202A-202E, U+2066-2069), the invisible hiders
61
+ * U+200B, U+2060 and U+FEFF, and the tag characters (U+E0000-E007F). Everything else is
62
+ * allowed, ZWJ and ZWNJ (U+200C/D, emoji sequences, Persian and Indic text) and the marks
63
+ * LRM, RLM and ALM (U+200E/F, U+061C) included. The Desktop uses the identical set. */
64
+ const WAITING_MESSAGE_REFUSED = /[\p{Cc}\u2028\u2029\u202A-\u202E\u2066-\u2069\u200B\u2060\uFEFF\u{E0000}-\u{E007F}]/u;
65
+ /** A claim's message: a non-empty string of 1 to 200 code points with none of
66
+ * WAITING_MESSAGE_REFUSED. The writer refuses anything else; the reader turns a stored
67
+ * message that fails it (a hand-edited log) into null. */
68
+ export function validWaitingMessage(m) {
69
+ return typeof m === "string" && m.length > 0 && [...m].length <= WAITING_MESSAGE_MAX && !WAITING_MESSAGE_REFUSED.test(m);
37
70
  }
71
+ // A reason read back from a log is shown as given only when it is one of the
72
+ // closed set; anything else reads as null, and the claim still counts.
73
+ /** A stored reason outside the closed set (a hand-edited or foreign row) reads as null. */
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));
38
77
 
39
- /** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
40
- export function incarnationOf(home) {
41
- try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; }
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 };
91
+ }
92
+
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`);
42
132
  }
43
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
+
44
156
  /** Append one typed event. Never throws into the caller's action: an event is
45
157
  * evidence, not authority — a failed write is reported in the return value. */
46
- export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
158
+ export function appendEvent(home, event, options) { return append(addressOf(home), event, options); }
159
+ function append(a, event, { workspaceOnly = false, incarnation, at } = {}) {
47
160
  if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
48
- const row = { eventsApi: EVENTS_API, 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 } : {}) };
161
+ // `at`: the time the fact became true, when it is recorded later (a start
162
+ // boundary reconciled from its receipt); readers order rows by it.
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 } : {}) };
49
165
  const line = JSON.stringify(row) + "\n";
50
166
  const results = [];
51
167
  // Retirement fingerprints the home against its baseline and preserves any
52
168
  // changed bytes as "unknown work": events written DURING retirement go only
53
169
  // to the workspace log, never into the home being inspected.
54
- for (const path of [...(workspaceOnly ? [] : [join(home, HOME_LOG)]), workspaceLogPath(home)]) {
170
+ for (const path of [...(workspaceOnly ? [] : [a.homeLog]), a.workspaceLog]) {
55
171
  try {
56
- 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; }
57
173
  mkdirSync(dirname(path), { recursive: true });
58
174
  appendFileSync(path, line);
59
175
  results.push({ path, ok: true });
@@ -96,47 +212,170 @@ function readLog(path, label) {
96
212
  return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
97
213
  }
98
214
 
99
- /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
100
- * window; integrity is reported independently of the selected rows. */
101
- export function readEvents(home, { limit = 200, since = null } = {}) {
215
+ /** The ADDRESS's rows from the given log reads: foreign rows dropped and
216
+ * counted, oldest first. The logs are merged as a multiset union: a row in
217
+ * both logs (the same fact, written to each) is kept once, but rows that
218
+ * genuinely repeat within one log (a set, a clear and the same set again in
219
+ * one millisecond) are all kept, as many times as the log holding the most
220
+ * copies has them. */
221
+ function admit(home, reads) {
102
222
  const instance = basename(home);
103
- const incarnation = incarnationOf(home);
104
- const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
105
- const seen = new Set(); const rows = []; let foreign = 0;
106
- for (const r of [...a.rows, ...b.rows]) {
107
- if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
108
- const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
109
- if (seen.has(k)) continue; seen.add(k); rows.push({ ...r, incarnation: r.incarnation ?? null });
223
+ const admitted = new Map(); const rows = []; let foreign = 0;
224
+ for (const read of reads) {
225
+ const inThisLog = new Map();
226
+ for (const r of read.rows) {
227
+ if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
228
+ const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
229
+ const n = (inThisLog.get(k) ?? 0) + 1; inThisLog.set(k, n);
230
+ if (n <= (admitted.get(k) ?? 0)) continue; // this copy is already in from another log
231
+ admitted.set(k, n); rows.push({ ...r, incarnation: r.incarnation ?? null });
232
+ }
110
233
  }
111
234
  rows.sort((x, y) => x.at.localeCompare(y.at));
112
- // Waiting: a producer STATE for the CURRENT incarnation, decided by that
113
- // producer's latest row that carries the field, over the FULL admitted read.
114
- // An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
115
- // unknown stays unknown, it never resurrects an earlier incarnation's positive.
235
+ return { rows, foreign };
236
+ }
237
+
238
+ /** Waiting: a producer STATE for the CURRENT incarnation, decided by that
239
+ * producer's latest row that carries the field, over the FULL admitted read.
240
+ * An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
241
+ * unknown stays unknown, it never resurrects an earlier incarnation's
242
+ * positive. A row older than the incarnation's latest kernel session
243
+ * boundary belongs to an ended session and is not a claim at all, nor is a
244
+ * row whose producer is neither `kernel` nor a valid producer id. */
245
+ function claimsOf(rows, incarnation) {
116
246
  const claims = new Map();
117
- for (const r of (incarnation === null ? [] : rows)) {
118
- if (r.incarnation !== incarnation) continue;
247
+ if (incarnation === null) return claims;
248
+ const current = rows.filter((r) => r.incarnation === incarnation);
249
+ // Positional: rows are time-sorted with a stable sort, so rows of the same
250
+ // millisecond keep their append order, and a claim appended before the
251
+ // boundary in that millisecond is still before it.
252
+ const boundary = current.findLastIndex((r) => (r.producer ?? "kernel") === "kernel" && WAITING_BOUNDARY_KINDS.includes(r.kind));
253
+ for (const r of current.slice(boundary + 1)) {
119
254
  if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
120
255
  const p = r.producer ?? "kernel";
121
- claims.set(p, r.data.waitingOnYou ? { producer: p, waiting: true, since: r.at, reason: typeof r.data.reason === "string" ? r.data.reason : null } : { producer: p, waiting: false, since: r.at, reason: null });
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;
259
+ claims.set(p, r.data.waitingOnYou
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 });
122
262
  }
263
+ return claims;
264
+ }
265
+ const positiveOf = (claims) => [...claims.values()].filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
266
+ const waitingShape = (c) => (c ? { since: c.since, producer: c.producer, reason: c.reason, message: c.message } : null);
267
+
268
+ /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
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");
275
+ const { rows, foreign } = admit(home, [a, b]);
276
+ const claims = claimsOf(rows, incarnation);
123
277
  const waitingClaims = [...claims.values()];
124
- const positive = waitingClaims.filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
278
+ const positive = positiveOf(claims);
125
279
  const filtered = since ? rows.filter((r) => r.at > since) : rows;
126
- const window = filtered.slice(-limit);
280
+ const window = filtered.slice(-limit).map((r) => answered(address, r));
127
281
  const last = window.at(-1) ?? null;
128
- return {
282
+ return answered(address, {
129
283
  eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
130
284
  truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
131
285
  integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
132
286
  events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
133
- waitingOnYou: positive ? { since: positive.since, producer: positive.producer, reason: positive.reason } : null,
287
+ waitingOnYou: waitingShape(positive),
134
288
  waitingClaims,
135
289
  notes: [
136
290
  "events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
137
291
  "this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
138
292
  "waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
293
+ "a claim older than the incarnation's latest kernel launched, restarted or stopped row belongs to an ended session and is not counted",
139
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",
140
295
  ],
141
- };
296
+ });
297
+ }
298
+
299
+ /** The instance's live waiting state, `{since, producer, reason, message}` or
300
+ * null: what `oats status` rows and `oats session inspect` carry. The same
301
+ * bounded read and the same claim rule as readEvents, over the home log only
302
+ * (the workspace log only when the home log is absent): every row a producer
303
+ * or a session boundary writes goes to both. */
304
+ export function liveWaiting(home) {
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)));
310
+ }
311
+
312
+ const waitingError = (code, message) => Object.assign(new Error(message), { code });
313
+
314
+ /** Set or clear one producer's waiting claim on an instance home; appends a
315
+ * `waiting` row only when the producer's LIVE claim changes (a positive set
316
+ * whose reason or message differs is a change; a clear of a claim that is
317
+ * not positive is not). Each log is judged on its own, so a row that reached
318
+ * only one log earlier is repaired by the next call rather than hidden by the
319
+ * other log; success means both logs took the row. Validates its input
320
+ * (E_BAD_ARGS); a home without a readable instance.json is
321
+ * E_SESSION_UNKNOWN; a write either log refused is E_EVENTS_FAILED (a retry
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 } = {}) {
325
+ if (typeof producer !== "string" || !WAITING_PRODUCER_RE.test(producer)) throw waitingError("E_BAD_ARGS", `--producer must match ${WAITING_PRODUCER_RE.source}`);
326
+ if (producer === "kernel") throw waitingError("E_BAD_ARGS", "--producer kernel is reserved for the kernel's own events");
327
+ if (typeof waiting !== "boolean") throw waitingError("E_BAD_ARGS", "waiting must be set or clear");
328
+ if (waiting) {
329
+ if (!WAITING_REASONS.includes(reason)) throw waitingError("E_BAD_ARGS", `--reason must be one of ${WAITING_REASONS.join(", ")}`);
330
+ if (message !== undefined && !validWaitingMessage(message)) throw waitingError("E_BAD_ARGS", `--message must be one line of 1 to ${WAITING_MESSAGE_MAX} characters with no control character, line separator, bidi control (U+202A-202E, U+2066-2069), U+200B, U+2060, U+FEFF or tag character`);
331
+ } else {
332
+ if (reason !== undefined) throw waitingError("E_BAD_ARGS", "--reason is for set, not clear");
333
+ if (message !== undefined) throw waitingError("E_BAD_ARGS", "--message is for set, not clear");
334
+ }
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")]
339
+ .map((log) => { const c = claimsOf(admit(home, [log]).rows, incarnation).get(producer); return c?.waiting ? c : null; });
340
+ const agrees = (c) => (waiting ? !!c && c.reason === reason && c.message === (message ?? null) : !c);
341
+ const answer = (changed, claim) => answered(a, { eventsApi: EVENTS_API, instance: a.instance, home, producer, changed, waitingOnYou: waitingShape(claim) });
342
+ if (live.every(agrees)) return answer(false, waiting ? live[0] : null);
343
+ const data = waiting ? { waitingOnYou: true, reason, ...(message !== undefined ? { message } : {}) } : { waitingOnYou: false };
344
+ const res = append(a, { producer, kind: "waiting", data }, { incarnation });
345
+ const failed = (res.results || []).filter((r) => !r.ok);
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`);
347
+ return answer(true, waiting ? { producer, since: res.row.at, reason, message: message ?? null } : null);
348
+ }
349
+
350
+ /** The kernel session boundary of one start receipt: a `launched` row tagged
351
+ * `startId`, dated at the receipt's `startedAt`, written once to each log. A
352
+ * start records it as soon as its session exists; recovering an interrupted
353
+ * start (its receipt adopted later) completes it: a log that already has the
354
+ * row is left alone, a log missing it gets a copy of the same row (same time,
355
+ * same data), and only when neither has it is the row made, still at the
356
+ * launch time, so a claim the new session made since is kept. `ok` is true
357
+ * only when every log holds the row; evidence, never authority. */
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];
362
+ const isIt = (r) => r.instance === instance && r.home === home && (r.producer ?? "kernel") === "kernel" && r.kind === "launched" && r.data?.startId === startId;
363
+ const found = paths.map((path) => readLog(path, "log").rows.find(isIt) ?? null);
364
+ if (found.every(Boolean)) return { ok: true, existed: true };
365
+ const existing = found.find(Boolean);
366
+ if (!existing) {
367
+ const res = append(a, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
368
+ return { ...res, ok: res.ok && (res.results || []).every((r) => r.ok) };
369
+ }
370
+ // The row exactly as the other log holds it: same time, incarnation and data.
371
+ const line = JSON.stringify(existing) + "\n";
372
+ const results = paths.filter((_, i) => !found[i]).map((path) => {
373
+ try {
374
+ if (path === a.homeLog && !existsSync(home)) return { path, ok: false, reason: "home absent" };
375
+ mkdirSync(dirname(path), { recursive: true });
376
+ appendFileSync(path, line);
377
+ return { path, ok: true };
378
+ } catch (e) { return { path, ok: false, reason: e.message }; }
379
+ });
380
+ return { ok: results.every((r) => r.ok), repaired: true, row: existing, results };
142
381
  }
@@ -0,0 +1,88 @@
1
+ /** Private entry point for schedule-command.mjs; no CLI or provider contract.
2
+ * stdin carries launch arguments, stdout carries the observed command result.
3
+ * Command output has separate pipes so it cannot forge that result. */
4
+ import { spawn } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { signalGroup } from "./process-group.mjs";
7
+
8
+ const { file, args, timeout, graceMs, maxBuffer } = JSON.parse(readFileSync(0, "utf8"));
9
+ const result = await new Promise((resolve) => {
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
+ let deadline, escalation, probe, groupEmpty = false;
13
+ const signalHandlers = new Map();
14
+ const chunks = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 };
15
+ const groupAlive = () => {
16
+ if (groupEmpty || !child?.pid) return false;
17
+ if (process.platform === "win32") return !exited;
18
+ try { process.kill(-child.pid, 0); return true; }
19
+ catch { groupEmpty = true; return false; }
20
+ };
21
+ const send = (sig) => {
22
+ if (process.platform === "win32") { if (!exited) child.kill(sig); }
23
+ else if (groupAlive()) signalGroup(child, sig);
24
+ };
25
+ const requestTerm = () => {
26
+ if (termSent || !child?.pid) return;
27
+ termSent = true;
28
+ send("SIGTERM");
29
+ };
30
+ const finish = () => {
31
+ if (settled || (!closed && !hardEnd) || (!exited && child?.pid)) return;
32
+ // A leader can close while a pipe-free descendant ignores TERM. Keep the
33
+ // supervisor alive until the grace ends or its group is observed empty.
34
+ if (stopping && !hardEnd && groupAlive()) return;
35
+ settled = true;
36
+ clearTimeout(deadline); clearTimeout(escalation); clearInterval(probe);
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));
42
+ };
43
+ const stop = (cause) => {
44
+ if (stopping || settled) return;
45
+ stopping = true; error ??= cause;
46
+ requestTerm();
47
+ escalation = setTimeout(() => {
48
+ send("SIGKILL"); hardEnd = true;
49
+ // A detached descendant can escape the group yet retain inherited pipes.
50
+ // Its pipes must not prevent returning after the direct child's exit.
51
+ child?.stdout?.destroy(); child?.stderr?.destroy();
52
+ finish();
53
+ }, graceMs);
54
+ watchGroup();
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
+ }
69
+ try { child = spawn(file, args, { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
70
+ catch (e) { error ??= { code: e.code, message: e.message }; closed = true; finish(); return; }
71
+ for (const stream of ["stdout", "stderr"]) child[stream].on("data", (chunk) => {
72
+ const room = maxBuffer - sizes[stream];
73
+ if (room > 0) { const kept = chunk.subarray(0, room); chunks[stream].push(kept); sizes[stream] += kept.length; }
74
+ if (chunk.length > room) stop({ code: "ENOBUFS", message: `schedule command ${stream} exceeded ${maxBuffer} bytes` });
75
+ });
76
+ child.once("error", (e) => { error ??= { code: e.code, message: e.message }; });
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
+ });
84
+ child.once("close", () => { closed = true; finish(); });
85
+ if (stopping) requestTerm(); // Shutdown may have begun while spawn returned.
86
+ deadline = setTimeout(() => stop({ code: "ETIMEDOUT", message: `schedule command exceeded ${timeout} ms` }), timeout);
87
+ });
88
+ process.stdout.write(JSON.stringify(result));
@@ -0,0 +1,26 @@
1
+ /** Synchronous scheduler child execution with an asynchronous timeout supervisor.
2
+ * The tick and its lock callbacks stay synchronous; only the supervisor owns
3
+ * the child, observes its exit and runs the TERM/KILL timers. Its stdout is a
4
+ * private result channel, never the command's stdout. */
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { TERM_GRACE_MS } from "./process-group.mjs";
8
+
9
+ const SUPERVISOR = fileURLToPath(new URL("./schedule-command-child.mjs", import.meta.url));
10
+ export function runScheduleCommand(file, args, { cwd, env, timeout = 300_000, graceMs = TERM_GRACE_MS, maxBuffer = 16 * 1024 * 1024 } = {}) {
11
+ for (const [name, value] of Object.entries({ timeout, graceMs, maxBuffer })) {
12
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2 ** 31 - 1) throw new TypeError(`${name} must be a positive 32-bit integer`);
13
+ }
14
+ const r = spawnSync(process.execPath, [SUPERVISOR], {
15
+ cwd, env, encoding: "utf8", input: JSON.stringify({ file, args, timeout, graceMs, maxBuffer }),
16
+ // Each output stream is byte-bounded. JSON can expand a byte to six bytes.
17
+ maxBuffer: maxBuffer * 12 + 65536,
18
+ });
19
+ if (!r.error && r.status === 0) {
20
+ try { return JSON.parse(r.stdout); } catch { /* no trusted supervisor receipt */ }
21
+ }
22
+ // A failed supervisor is not evidence that its command exited. The scheduler
23
+ // conservatively retains the attempt and slot when there is no receipt.
24
+ return { status: null, signal: null, stdout: "", stderr: String(r.stderr || ""),
25
+ error: { code: r.error?.code || "E_SCHEDULE_RUNNER", message: r.error?.message || "schedule child supervisor returned no result" } };
26
+ }