@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.
- package/bin/oats.mjs +106 -16
- package/docs/capabilities.md +249 -2
- package/docs/desktop-cli-api.md +246 -18
- package/docs/execution-targets.md +34 -29
- package/docs/implementation.md +62 -0
- package/docs/oats-local.schema.json +2 -2
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +5 -5
- package/docs/release-lane.md +8 -4
- package/docs/release-notes/v0.40.0.md +181 -0
- package/docs/release-notes/v0.40.1.md +20 -0
- package/docs/release-notes/v0.40.2.md +120 -0
- package/docs/schedules.md +117 -19
- package/docs/servers.md +3 -1
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +4 -1
- package/lib/core.mjs +76 -21
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +278 -39
- package/lib/schedule-command-child.mjs +88 -0
- package/lib/schedule-command.mjs +26 -0
- package/lib/schedule.mjs +175 -41
- package/lib/servers.mjs +21 -3
- package/lib/session-input.mjs +42 -47
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/lib/instance-events.mjs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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, {
|
|
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
|
-
|
|
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 ? [] : [
|
|
170
|
+
for (const path of [...(workspaceOnly ? [] : [a.homeLog]), a.workspaceLog]) {
|
|
55
171
|
try {
|
|
56
|
-
if (path.
|
|
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
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|