@awebai/oats 0.22.9 → 0.22.12

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.
@@ -0,0 +1,731 @@
1
+ /** OATS schedules v1: workspace-scoped definitions, host-owned execution.
2
+ *
3
+ * A definition lives in <scope>/oats-schedules.json, where the scope is the
4
+ * team workspace (the config level declaring the team, else the outermost
5
+ * oats-config.yaml level), and is evaluated by the host that holds that
6
+ * scope: one host registry, one host lock, one host timer that runs
7
+ * `oats schedule tick --host` once a minute. The tick is a short-lived
8
+ * process; there is no daemon, no queue and no retry. Only the current
9
+ * minute is evaluated, so minutes missed while the machine slept are
10
+ * skipped, never replayed. Cron expressions (five fields, an explicit IANA
11
+ * zone) are evaluated by croner; nothing here parses cron.
12
+ *
13
+ * Three kinds: `spawn` launches one disposable instance per due minute,
14
+ * `command` runs an oats-only argv in a scope cwd and tracks any instance
15
+ * the envelope names, `wake` starts an existing home when it is stopped and
16
+ * delivers a literal message once when it is active. Outcomes say what was
17
+ * observed (launched, active, ended, stopped, launch-failed, unknown;
18
+ * delivered, started, skipped for wake) and never claim that a task
19
+ * succeeded. A launch whose side effects cannot be confirmed stays
20
+ * `unknown` with its slot held until `reconcile` proves what happened. */
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { Cron } from "croner";
27
+ import { ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
28
+
29
+ export const SCHEDULE_FILE = "oats-schedules.json";
30
+ export const SCHEDULE_API = 1;
31
+ const ID_RE = /^[a-z0-9-]{1,40}$/;
32
+ const RUNTIMES = new Set(["pi", "claude", "codex"]);
33
+ const BACKENDS = new Set(["tmux", "herdr"]);
34
+ const MESSAGE_MAX = 256 * 1024;
35
+ const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
36
+ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
37
+
38
+ export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
39
+ /** A stored definition that is not even shaped like one (a hand edit): it
40
+ * is never observed or executed, and is reported invalid on that job only. */
41
+ export function shapeError(def) {
42
+ if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
43
+ if (!["spawn", "command", "wake"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command or wake`;
44
+ if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
45
+ if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
46
+ if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
47
+ if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
48
+ return null;
49
+ }
50
+
51
+ // ---------------------------------------------------------------- scope
52
+
53
+ /** The one schedule-owning scope for a directory: the config level that
54
+ * declares the team (the deployment boundary), else the outermost level
55
+ * carrying oats-config.yaml, else the workspace above the agents root. */
56
+ export function scheduleScopeOf(dir) {
57
+ const start = resolve(dir);
58
+ let chain = [];
59
+ try { chain = configChain(start); } catch { chain = []; }
60
+ const team = chain.find((c) => c.team);
61
+ if (team?._level) return resolve(team._level);
62
+ if (chain.length) return resolve(chain[chain.length - 1]._level);
63
+ try { return dirname(ensureRoot(start)); } catch { return start; }
64
+ }
65
+ /** Every agents root the scope knows: its own, member repositories', and any
66
+ * root that holds instance homes. */
67
+ export function scopeRoots(scope) {
68
+ const roots = new Set();
69
+ try { roots.add(resolve(ensureRoot(scope))); } catch { /* a scope with no agents dir */ }
70
+ try { for (const r of teamAgentRoots(scope)) roots.add(resolve(r)); } catch { /* not a team scope */ }
71
+ return [...roots];
72
+ }
73
+
74
+ // ------------------------------------------------------------- files
75
+
76
+ export function oatsHomeDir() { return process.env.OATS_HOME_DIR || join(homedir(), ".oats"); }
77
+ export function hostScheduleDir() { return join(oatsHomeDir(), "schedules"); }
78
+ export function definitionsPath(ws) { return join(ws, SCHEDULE_FILE); }
79
+ export function stateDir(ws) { return join(ws, ".agents", "schedules"); }
80
+ function statePath(ws) { return join(stateDir(ws), "state.json"); }
81
+ function lockDir(ws, id) { return join(stateDir(ws), "locks", id); }
82
+
83
+ function readJson(path, fallback) {
84
+ if (!existsSync(path)) return fallback;
85
+ try { return JSON.parse(readFileSync(path, "utf8")); }
86
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file" }); }
87
+ }
88
+ function writeJson(path, value) {
89
+ mkdirSync(dirname(path), { recursive: true });
90
+ const tmp = `${path}.tmp-${process.pid}`;
91
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n");
92
+ renameSync(tmp, path);
93
+ }
94
+ export function readDefinitions(ws) {
95
+ const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
96
+ if (doc.version !== 1 || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1, jobs: {}}`, { field: "file" });
97
+ return doc;
98
+ }
99
+ export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
100
+ export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
101
+ export function writeState(ws, st) { writeJson(statePath(ws), st); }
102
+
103
+ // ------------------------------------------------------- host registry
104
+
105
+ export function readRegistry() {
106
+ const reg = readJson(join(hostScheduleDir(), "registry.json"), { version: 1, maxConcurrent: 1, tickIntervalSec: 60, workspaces: [] });
107
+ if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
108
+ if (!Number.isInteger(reg.maxConcurrent) || reg.maxConcurrent < 1) reg.maxConcurrent = 1;
109
+ if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
110
+ return reg;
111
+ }
112
+ export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
113
+ export function registerWorkspace(ws) { return withRegistryLock(() => { const reg = readRegistry(); const abs = resolve(ws); if (!reg.workspaces.includes(abs)) { reg.workspaces.push(abs); writeRegistry(reg); } return reg; }); }
114
+ export function unregisterWorkspace(ws) { return withRegistryLock(() => { const reg = readRegistry(); reg.workspaces = reg.workspaces.filter((w) => w !== resolve(ws)); writeRegistry(reg); return reg; }); }
115
+ export function readHostState() { return readJson(join(hostScheduleDir(), "state.json"), {}); }
116
+ export function writeHostState(st) { writeJson(join(hostScheduleDir(), "state.json"), st); }
117
+
118
+ // ---------------------------------------------------------------- locks
119
+
120
+ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
121
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
122
+
123
+ /** A mkdir lock that is never reclaimed by another process: an existing
124
+ * lock whose owner is unreadable or dead is refused with the directory to
125
+ * remove, because the gap between mkdir and owner.json belongs to a live
126
+ * acquirer and a dead-owner reclaim races every other acquirer. The
127
+ * holder removes its own lock in finally and on SIGINT/SIGTERM. */
128
+ function withDirLock(dir, what, fn, { retryMs = 0 } = {}) {
129
+ mkdirSync(dirname(dir), { recursive: true });
130
+ const deadline = Date.now() + retryMs;
131
+ for (;;) {
132
+ try { mkdirSync(dir); break; }
133
+ catch (e) {
134
+ if (e.code !== "EEXIST") throw e;
135
+ let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
136
+ if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
137
+ const why = !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`;
138
+ throw scheduleError("E_SCHEDULER_BUSY", `${what} is locked (${why}). If no oats schedule process is running, remove ${dir} and retry; nothing was changed.`);
139
+ }
140
+ }
141
+ writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid: process.pid, at: new Date().toISOString(), what }) + "\n");
142
+ const release = () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
143
+ const onSignal = (sig) => { release(); process.exit(sig === "SIGINT" ? 130 : 143); };
144
+ process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
145
+ try { return fn(); }
146
+ finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); release(); }
147
+ }
148
+ export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join(hostScheduleDir(), "host.lock"), "the host scheduler", fn, { retryMs }); }
149
+ /** Registry read-modify-write is serialized on its own short lock. */
150
+ function withRegistryLock(fn) { return withDirLock(join(hostScheduleDir(), "registry.lock"), "the host schedule registry", fn, { retryMs: 3000 }); }
151
+ /** Definitions and state are read-modify-write; CRUD serializes here briefly. */
152
+ function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
153
+
154
+ export function acquireJobLock(ws, id, owner) {
155
+ const dir = lockDir(ws, id);
156
+ mkdirSync(dirname(dir), { recursive: true });
157
+ try { mkdirSync(dir); } catch (e) { if (e.code === "EEXIST") return false; throw e; }
158
+ writeFileSync(join(dir, "owner.json"), JSON.stringify({ ...owner, pid: process.pid, at: new Date().toISOString() }, null, 2) + "\n");
159
+ return true;
160
+ }
161
+ export function jobLockInfo(ws, id) {
162
+ const dir = lockDir(ws, id);
163
+ if (!existsSync(dir)) return null;
164
+ try { return JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { return { unreadable: true }; }
165
+ }
166
+ export function releaseJobLock(ws, id) { rmSync(lockDir(ws, id), { recursive: true, force: true }); }
167
+ function liveLocks(wsList) {
168
+ let n = 0;
169
+ for (const ws of wsList) {
170
+ const dir = join(stateDir(ws), "locks");
171
+ if (!existsSync(dir)) continue;
172
+ for (const e of readdirSync(dir, { withFileTypes: true })) if (e.isDirectory()) n++;
173
+ }
174
+ return n;
175
+ }
176
+
177
+ // ------------------------------------------------------------ validation
178
+
179
+ function inside(ws, p) { return (resolve(p) + sep).startsWith(resolve(ws) + sep); }
180
+ export function validateCron(cron, tz, field = "cron") {
181
+ if (typeof cron !== "string" || cron.trim().split(/\s+/).length !== 5) throw scheduleError("E_SCHEDULE_INVALID", `${field}: five fields required (minute hour day month weekday)`, { field });
182
+ if (typeof tz !== "string" || !tz.trim()) throw scheduleError("E_SCHEDULE_INVALID", "tz: an explicit IANA time zone is required", { field: "tz" });
183
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); } catch { throw scheduleError("E_SCHEDULE_INVALID", `tz: unknown time zone ${JSON.stringify(tz)}`, { field: "tz" }); }
184
+ try { return new Cron(cron.trim(), { timezone: tz, paused: true }); }
185
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${field}: ${e.message}`, { field }); }
186
+ }
187
+ function validateMessage(message, field) {
188
+ if (typeof message !== "string" || !message.trim() || message.includes("\0") || Buffer.byteLength(message) > MESSAGE_MAX) throw scheduleError("E_SCHEDULE_INVALID", `${field}: non-empty text without NUL, at most 256 KiB`, { field });
189
+ }
190
+
191
+ /** Validate and normalize one definition against its scope. */
192
+ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
193
+ if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
194
+ const id = def.id;
195
+ if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to 40 characters", { field: "id" });
196
+ const enabled = def.enabled === undefined ? true : def.enabled;
197
+ if (typeof enabled !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "enabled: boolean", { field: "enabled" });
198
+ validateCron(def.cron, def.tz);
199
+ const out = { id, enabled, cron: def.cron.trim(), tz: def.tz.trim(), kind: def.kind };
200
+ if (def.kind === "spawn") {
201
+ if (typeof def.agent !== "string" || !def.agent.trim()) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required", { field: "agent" });
202
+ out.agent = def.agent.trim();
203
+ if (def.agentsRoot !== undefined) {
204
+ const known = scopeRoots(ws);
205
+ if (typeof def.agentsRoot !== "string" || !isAbsolute(def.agentsRoot) || !inside(ws, def.agentsRoot) || !known.includes(resolve(def.agentsRoot))) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: must be one of this scope's agents roots (${known.join(", ") || "none"})`, { field: "agentsRoot" });
206
+ out.agentsRoot = resolve(def.agentsRoot);
207
+ }
208
+ if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
209
+ if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
210
+ if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
211
+ if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
212
+ out.task = def.task;
213
+ if (def.runtime !== undefined) { if (!RUNTIMES.has(def.runtime)) throw scheduleError("E_SCHEDULE_INVALID", "runtime: pi, claude or codex", { field: "runtime" }); out.runtime = def.runtime; }
214
+ if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
215
+ if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
216
+ if (def.wake !== undefined) {
217
+ const w = def.wake;
218
+ if (!w || typeof w !== "object") throw scheduleError("E_SCHEDULE_INVALID", "wake: {cron, tz, message}", { field: "wake" });
219
+ validateCron(w.cron, w.tz, "wake.cron"); validateMessage(w.message, "wake.message");
220
+ out.wake = { cron: w.cron.trim(), tz: w.tz.trim(), message: w.message };
221
+ }
222
+ if (checkAgent) resolveScheduledAgent(ws, out);
223
+ } else if (def.kind === "command") {
224
+ if (typeof def.cwd !== "string" || !isAbsolute(def.cwd) || !inside(ws, def.cwd) || !existsSync(def.cwd)) throw scheduleError("E_SCHEDULE_INVALID", "cwd: an existing absolute directory inside the scope", { field: "cwd" });
225
+ if (!Array.isArray(def.argv) || !def.argv.length || def.argv.some((a) => typeof a !== "string" || a.includes("\0")) || def.argv[0] !== "oats") throw scheduleError("E_SCHEDULE_INVALID", "argv: oats-only argument list starting with \"oats\"", { field: "argv" });
226
+ if (def.argv[1] === "schedule") throw scheduleError("E_SCHEDULE_INVALID", "argv: a command job may not run oats schedule (it would wait on the host lock the tick holds)", { field: "argv" });
227
+ out.cwd = resolve(def.cwd); out.argv = [...def.argv];
228
+ } else if (def.kind === "wake") {
229
+ if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
230
+ validateMessage(def.message, "message");
231
+ out.home = resolve(def.home); out.message = def.message;
232
+ } else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command or wake", { field: "kind" });
233
+ return out;
234
+ }
235
+
236
+ /** The soul a spawn job launches: the exact agents root the definition
237
+ * names (agentsRoot, what tells same-named souls in different member
238
+ * repositories apart), else the scope's own root; the same lookups `oats
239
+ * spawn` uses (local and persistent souls, then capability-declared
240
+ * agents). `repo` is the work repository and never selects a soul. */
241
+ export function resolveScheduledAgent(ws, def) {
242
+ let root;
243
+ try { root = def.agentsRoot ? ensureRoot(def.agentsRoot) : ensureRoot(ws); }
244
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${e.message}`, { field: def.agentsRoot ? "agentsRoot" : "agent" }); }
245
+ if (def.agentsRoot && resolve(root) !== resolve(def.agentsRoot)) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not an agents root (resolved to ${root})`, { field: "agentsRoot" });
246
+ let agent = findAgent(root, def.agent);
247
+ if (!agent) { try { agent = findCapabilityAgent(dirname(root), root, def.agent); } catch { agent = undefined; } }
248
+ if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
249
+ return { root, agent };
250
+ }
251
+
252
+ // ----------------------------------------------------------------- time
253
+
254
+ export function minuteStart(now) { return new Date(Math.floor(now.getTime() / 60000) * 60000); }
255
+ export function minuteKey(date) { return date.toISOString().slice(0, 16); }
256
+ export function cronOf(def) { return new Cron(def.cron, { timezone: def.tz, paused: true }); }
257
+ export function nextRunAfter(def, from) { const n = cronOf(def).nextRun(from); return n ? n.toISOString() : null; }
258
+ /** Due exactly at this minute: the first run after (minute - 1 s) is this minute. */
259
+ export function dueAt(def, minute) { const n = cronOf(def).nextRun(new Date(minute.getTime() - 1000)); return !!n && n.getTime() === minute.getTime(); }
260
+ export function purposeSuffix(minute) { return minute.toISOString().slice(0, 16).replace(/[-T:]/g, ""); }
261
+ /** Ordering key for admission: when this job last launched a runtime. */
262
+ export function launchOrderKey(js) { return String(js?.lastLaunchedAt || ""); }
263
+
264
+ // ---------------------------------------------------------- observation
265
+
266
+ /** What a run's instance looks like now. Home gone = ended; a home that is
267
+ * retiring is still there (its runtime may be alive pending teardown) and
268
+ * keeps its slot; present but no running harness = stopped (needs
269
+ * attention, never removed); running = active; not inspectable = unknown. */
270
+ export function observeHome(home, io) {
271
+ if (!home) return { outcome: "launched" };
272
+ if (!existsSync(home)) return { outcome: "ended" };
273
+ const retiring = existsSync(retirePendingMarkerPath(home));
274
+ try {
275
+ const s = (io?.inspect || inspectInstanceSession)(home);
276
+ if (s.present && s.state !== "shell") return { outcome: "active", ...(retiring ? { note: "retiring" } : {}) };
277
+ if (retiring) return { outcome: "active", note: "retiring: home present, teardown pending" };
278
+ // A shell right after `session start` is that launch's startup phase
279
+ // until its wrapper writes the exit marker for the launch id; only then
280
+ // (or with no start receipt at all) is a shell a finished runtime.
281
+ if (s.present && s.state === "shell" && startupInProgress(home)) return { outcome: "starting" };
282
+ return { outcome: "stopped" };
283
+ } catch (e) { return { outcome: "unknown", error: e.message }; }
284
+ }
285
+ function startupInProgress(home) {
286
+ const pendingPath = join(home, ".oats-start-pending.json");
287
+ if (!existsSync(pendingPath)) return false;
288
+ let pending; try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { return true; }
289
+ const exitedPath = join(home, ".oats-start-exited");
290
+ if (!existsSync(exitedPath)) return true;
291
+ try { return readFileSync(exitedPath, "utf8").trim() !== String(pending.id); } catch { return true; }
292
+ }
293
+
294
+ /** All homes for an instance name across the scope's roster (every known
295
+ * agents root and its local-agents sibling). */
296
+ export function findHomesInScope(ws, instance) {
297
+ const homes = new Set();
298
+ for (const root of scopeRoots(ws)) {
299
+ try { for (const h of findInstanceHomes(root, instance)) homes.add(resolve(h.home || h)); } catch { /* unreadable root */ }
300
+ const local = join(dirname(root), "local-agents");
301
+ if (existsSync(local)) for (const e of readdirSync(local, { withFileTypes: true })) { if (!e.isDirectory()) continue; const h = join(local, e.name, "instances", instance); if (existsSync(join(h, "instance.json"))) homes.add(resolve(h)); }
302
+ }
303
+ return [...homes];
304
+ }
305
+ // --------------------------------------------------------------- runner
306
+
307
+ function scheduleBlock(def, minute) {
308
+ return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.id}" for ${minute.toISOString()} (cron "${def.cron}" in ${def.tz}). It is disposable: finish the task above, bring your memory files up to date, and end with \`oats retire --self\`.\n`;
309
+ }
310
+ /** Launch one spawn job. `io.spawn(root, agent, opts)` overrides spawnInstance for tests. */
311
+ function launchSpawn(ws, def, minute, io) {
312
+ const { root, agent } = resolveScheduledAgent(ws, def);
313
+ const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
314
+ const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: true };
315
+ const r = (io?.spawn || spawnInstance)(root, agent, opts);
316
+ const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn" };
317
+ if (def.wake && r.home) {
318
+ try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
319
+ catch (e) { run.wakeScheduleError = { code: e.code || "E_SCHEDULE_INVALID", message: e.message }; }
320
+ }
321
+ return run;
322
+ }
323
+ /** Parse a command's stdout as one JSON document (remote output can be
324
+ * multi-line); fall back to the last {...} block; null when nothing parses. */
325
+ export function parseEnvelopeText(text) {
326
+ const s = String(text || "").trim();
327
+ if (!s) return null;
328
+ try { return JSON.parse(s); } catch { /* fall through */ }
329
+ const i = s.lastIndexOf("\n{");
330
+ if (i >= 0) { try { return JSON.parse(s.slice(i + 1)); } catch { /* fall through */ } }
331
+ const j = s.indexOf("{");
332
+ if (j >= 0) { try { return JSON.parse(s.slice(j)); } catch { /* give up */ } }
333
+ return null;
334
+ }
335
+ /** Run one command job: oats-only argv in the job's cwd. A timeout or an
336
+ * unparseable answer means the side effects are unconfirmed: `unknown`,
337
+ * never `launch-failed`. `io.oatsBin` and `io.commandTimeoutMs` are test seams. */
338
+ function launchCommand(ws, def, io) {
339
+ const argv = def.argv.includes("--json") ? def.argv.slice(1) : [...def.argv.slice(1), "--json"];
340
+ let envelope, timedOut = false, raw = "";
341
+ if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
342
+ else {
343
+ const env = { ...process.env }; delete env.OATS_INSTANCE; delete env.OATS_INSTANCE_HOME; delete env.PI_AGENT_INSTANCE; delete env.PI_AGENT_HOME;
344
+ const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
345
+ timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
346
+ raw = String(r.stdout || "");
347
+ envelope = parseEnvelopeText(raw);
348
+ }
349
+ // No envelope, a malformed one, or a timeout: the process may still have
350
+ // created a home. That is unconfirmed, never a confirmed failure.
351
+ if (timedOut || !envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") return { kind: "command", launched: false, unconfirmed: true, error: timedOut ? "command timed out; its side effects are unconfirmed" : "command answered no valid envelope; its side effects are unconfirmed" };
352
+ const res = envelope?.result || {};
353
+ const instance = typeof res.instance === "string" ? res.instance : undefined;
354
+ let home = typeof res.home === "string" ? res.home : undefined;
355
+ if (instance && !home) { const found = findHomesInScope(ws, instance); if (found.length === 1) home = found[0]; }
356
+ if (envelope.ok && instance && !home) return { kind: "command", launched: true, instance, unconfirmed: true, error: `the envelope names instance ${instance} but no home for it is in this scope's roster; run oats schedule reconcile once it appears` };
357
+ // A valid ok:false answer that reports an incomplete rollback (a harvest
358
+ // spawn whose compensation could not stop or remove everything) is not a
359
+ // confirmed failure either.
360
+ if (!envelope.ok && reportsRetainedEffects(envelope.error?.message)) return { kind: "command", launched: false, unconfirmed: true, ...(instance ? { instance } : {}), error: `command failed with retained effects: ${envelope.error?.message}`, errorCode: envelope.error?.code };
361
+ return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
362
+ }
363
+ /** One wake. `startIfStopped` false between due minutes (a harness that
364
+ * keeps exiting is not restarted every minute); `canStart` false when the
365
+ * host bound is reached (a new runtime takes a slot; a delivery does not). */
366
+ /** A failure message that reports effects the kernel could not undo or
367
+ * confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
368
+ * retained home): the launch's effects are unconfirmed, never a confirmed
369
+ * failure. Shared by caught spawn errors and ok:false command envelopes. */
370
+ export function reportsRetainedEffects(message) { return /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || "")); }
371
+ function performWake(def, io, { startIfStopped = true, canStart = true, reserve } = {}) {
372
+ const seen = observeHome(def.home, io);
373
+ if (seen.outcome === "ended") return { kind: "wake", action: "skipped", reason: "home is gone", pending: false };
374
+ if (seen.outcome === "unknown") return { kind: "wake", action: "skipped", reason: `cannot observe the session: ${seen.error}`, pending: true };
375
+ if (seen.outcome === "starting") return { kind: "wake", action: "skipped", reason: "session is starting; delivery pending", pending: true };
376
+ if (seen.outcome === "stopped") {
377
+ if (!startIfStopped) return { kind: "wake", action: "skipped", reason: "session stopped; it is started again only at the next due minute; delivery pending", pending: true };
378
+ if (!canStart) return { kind: "wake", action: "skipped", reason: "host busy: starting this session needs a launch slot; delivery pending", pending: true };
379
+ // The slot is persisted BEFORE the start call and kept on ANY start
380
+ // exception: an error code does not prove nothing was allocated (core
381
+ // can refuse while recording, after the session exists). The next
382
+ // observation releases it once the runtime is proven stopped or absent;
383
+ // at worst a refused start occupies a slot for one tick.
384
+ if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
385
+ try {
386
+ const r = (io?.start || startInstanceSession)(def.home);
387
+ return { kind: "wake", action: "started", instance: r.instance, target: r.target, reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
388
+ } catch (e) {
389
+ return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
390
+ }
391
+ }
392
+ try { (io?.input || inputInstanceSession)(def.home, def.message); return { kind: "wake", action: "delivered", pending: false }; }
393
+ catch (e) { return { kind: "wake", action: "skipped", reason: `input refused: ${e.message}`, pending: true }; }
394
+ }
395
+
396
+ // -------------------------------------------------------------- tick
397
+
398
+ /** Observe the previous run of one job. The tracked identity is what was
399
+ * launched (the lock's or lastRun's home), never the current definition,
400
+ * so an edit cannot make a slot forget its runtime. A wake slot is held
401
+ * while its started runtime is active, starting, unknown or retiring and
402
+ * released when that runtime is proven stopped (the start receipt's exit
403
+ * marker) or its home is gone: a persistent home outliving its process
404
+ * does not keep a slot. */
405
+ function observePrevious(ws, id, def, js, io, now, { mutate }) {
406
+ if (shapeError(def)) return;
407
+ const lr = js.lastRun;
408
+ const lock = jobLockInfo(ws, id);
409
+ const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
410
+ const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
411
+ if (!tracked || !home) return;
412
+ const seen = observeHome(home, io);
413
+ if (!mutate) return;
414
+ if (def.kind === "wake") {
415
+ if (seen.outcome === "ended" || seen.outcome === "stopped") {
416
+ releaseJobLock(ws, id);
417
+ if (seen.outcome === "ended") delete js.pendingWake;
418
+ if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started runtime ended" : "started runtime stopped; slot released" };
419
+ }
420
+ return;
421
+ }
422
+ js.lastRun = { ...lr, outcome: seen.outcome === "starting" ? "active" : seen.outcome, ...(seen.error ? { error: seen.error } : {}), ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : {}) };
423
+ if (seen.outcome === "ended") releaseJobLock(ws, id);
424
+ }
425
+
426
+ /** Evaluate every enabled job of `ws` for the current minute (or, with
427
+ * `only`, run that one job now). Caller holds the host lock. `dryRun`
428
+ * reads only: no lock, state or definition is touched. Due jobs are
429
+ * visited least-recently-launched first so one job cannot monopolize the
430
+ * slot. An invalid definition is recorded on that job and the rest continue. */
431
+ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun = false, only, observeOnly = false, candidates } = {}) {
432
+ const minute = minuteStart(now);
433
+ const key = minuteKey(minute);
434
+ const registry = reg || readRegistry();
435
+ const bound = registry.maxConcurrent || 1;
436
+ const wsAll = wsList || [ws];
437
+ const defs = readDefinitions(ws);
438
+ const st = readState(ws);
439
+ const considered = [];
440
+ const stateOf = (id) => (st.jobs[id] ||= {});
441
+ const record = (id, action, extra = {}) => considered.push({ workspace: ws, id, minute: key, action, ...extra });
442
+ // First observe every job's previous run (freeing the slots of homes that
443
+ // are gone), then decide launches: a job ordered earlier must not see a
444
+ // slot still held by a home that has already ended.
445
+ // `candidates` (the host tick) means the host already observed every scope.
446
+ if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
447
+ if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
448
+ // Least recently LAUNCHED first: only an actual runtime launch counts, a
449
+ // skipped or pending job keeps its place at the front of the line.
450
+ const order = (candidates || Object.keys(defs.jobs)).filter((id) => Object.prototype.hasOwnProperty.call(defs.jobs, id) && (!only || id === only)).sort((a, b) => launchOrderKey(st.jobs[a]).localeCompare(launchOrderKey(st.jobs[b])) || a.localeCompare(b));
451
+ for (const id of order) {
452
+ const def = defs.jobs[id];
453
+ const js = stateOf(id);
454
+ let due, cronError;
455
+ const shape = shapeError(def);
456
+ if (shape) { cronError = shape; due = false; }
457
+ else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
458
+ if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
459
+ if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
460
+ const dueNow = due && js.lastAttemptedMinute !== key;
461
+ const held = !!jobLockInfo(ws, id);
462
+ const canStart = held || liveLocks(wsAll) < bound;
463
+ const run = performWake(def, io, { startIfStopped: dueNow, canStart, reserve: () => held || acquireJobLock(ws, id, { scheduledFor: js.pendingWake.scheduledFor, wake: true, home: def.home }) });
464
+ if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
465
+ if (!existsSync(def.home)) releaseJobLock(ws, id);
466
+ js.lastRun = { ...(js.lastRun || {}), ...run, outcome: run.action, completedPending: run.action === "delivered", pendingSince: js.pendingWake.scheduledFor, startedAt: js.lastRun?.startedAt || now.toISOString() };
467
+ if (!run.pending) delete js.pendingWake;
468
+ if (dueNow) js.lastAttemptedMinute = key;
469
+ record(id, run.action, { ...(run.reason ? { reason: run.reason } : {}), pending: !!run.pending });
470
+ continue;
471
+ }
472
+ if (!def.enabled && !only) continue;
473
+ if (js.lastAttemptedMinute === key && !only) continue;
474
+ if (!due) continue;
475
+ if (dryRun) {
476
+ const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : undefined;
477
+ record(id, reason ? "skipped" : "due", reason ? { reason } : {});
478
+ continue;
479
+ }
480
+ js.lastAttemptedMinute = key;
481
+ if (js.attempt) { record(id, "skipped", { reason: "an earlier launch has no recorded result; run oats schedule reconcile" }); js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: "launch attempt without a recorded result", scheduledFor: js.attempt.scheduledFor }; continue; }
482
+ if (def.kind === "wake") {
483
+ // A delivery to a running session takes no slot; starting a stopped
484
+ // one does, and the slot is held (the job lock) until that home ends.
485
+ const holdsSlot = !!jobLockInfo(ws, id);
486
+ const run = performWake(def, io, { startIfStopped: true, canStart: holdsSlot || liveLocks(wsAll) < bound, reserve: () => holdsSlot || acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), wake: true, home: def.home }) });
487
+ if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
488
+ if (!existsSync(def.home)) releaseJobLock(ws, id);
489
+ if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
490
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action };
491
+ record(id, run.action, { ...(run.reason ? { reason: run.reason } : {}), pending: !!run.pending });
492
+ continue;
493
+ }
494
+ if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
495
+ if (liveLocks(wsAll) >= bound) { record(id, "skipped", { reason: "host busy" }); continue; }
496
+ if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString() })) { record(id, "skipped", { reason: "still running" }); continue; }
497
+ js.attempt = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString() };
498
+ js.lastLaunchedAt = now.toISOString();
499
+ writeState(ws, st);
500
+ let run;
501
+ try { run = def.kind === "command" ? launchCommand(ws, def, io) : launchSpawn(ws, def, minute, io); }
502
+ catch (e) {
503
+ // spawnInstance compensates its own failures, but its rollback can be
504
+ // INCOMPLETE (a pane it could not stop, a quarantined home it kept):
505
+ // then the effects are unconfirmed. A command that threw is
506
+ // unconfirmed as well.
507
+ run = { kind: def.kind, launched: false, error: e.message, errorCode: e.code, unconfirmed: def.kind !== "spawn" || reportsRetainedEffects(e.message) };
508
+ }
509
+ if (run.unconfirmed) {
510
+ // Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
511
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
512
+ record(id, "unknown", { error: run.error });
513
+ continue;
514
+ }
515
+ delete js.attempt;
516
+ const outcome = run.launched ? "launched" : "launch-failed";
517
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
518
+ if (!run.launched || !run.home) releaseJobLock(ws, id);
519
+ record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
520
+ }
521
+ if (!dryRun) writeState(ws, st);
522
+ return considered;
523
+ }
524
+
525
+ /** The host tick: every registered scope under the host lock. */
526
+ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
527
+ const reg = readRegistry();
528
+ const wsList = reg.workspaces.filter((w) => existsSync(w));
529
+ const body = () => {
530
+ const considered = [];
531
+ // Observe every registered scope first, then admit in one host-wide
532
+ // order (least recently launched first, then scope, then id), so an
533
+ // every-minute job in the first registered scope cannot monopolize the
534
+ // bound. An unreadable scope is recorded and the rest continue.
535
+ const jobs = [];
536
+ for (const ws of wsList) {
537
+ try {
538
+ considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true }));
539
+ const defs = readDefinitions(ws), st = readState(ws);
540
+ for (const id of Object.keys(defs.jobs)) jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
541
+ } catch (e) { considered.push({ workspace: ws, action: "error", error: e.message }); }
542
+ }
543
+ jobs.sort((a, b) => a.key.localeCompare(b.key) || a.ws.localeCompare(b.ws) || a.id.localeCompare(b.id));
544
+ for (const j of jobs) {
545
+ try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id] })); }
546
+ catch (e) { considered.push({ workspace: j.ws, id: j.id, action: "error", error: e.message }); }
547
+ }
548
+ if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
549
+ return { tickedAt: now.toISOString(), minute: minuteKey(minuteStart(now)), considered, scheduler: schedulerStatus(undefined, io) };
550
+ };
551
+ return dryRun ? body() : withHostLock(body);
552
+ }
553
+
554
+ /** Run one job now, ignoring its cron, under the same lock and bound; the
555
+ * previous run is observed under the host lock before any lock is judged. */
556
+ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
557
+ const defs = readDefinitions(ws);
558
+ const def = defs.jobs[id];
559
+ if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
560
+ if (!def.enabled && !force) throw scheduleError("E_SCHEDULE_DISABLED", `schedule ${id} is disabled; enable it or pass --force`);
561
+ const reg = readRegistry();
562
+ const wsList = reg.workspaces.includes(resolve(ws)) ? reg.workspaces : [...reg.workspaces, resolve(ws)];
563
+ return withHostLock(() => {
564
+ const st = readState(ws);
565
+ const js = st.jobs[id] || (st.jobs[id] = {});
566
+ observePrevious(ws, id, def, js, io, now, { mutate: true });
567
+ writeState(ws, st);
568
+ if (js.attempt) throw scheduleError("E_SCHEDULE_UNRESOLVED", `schedule ${id} has a launch attempt without a recorded result; run oats schedule reconcile ${id} first`);
569
+ if (def.kind !== "wake" && jobLockInfo(ws, id)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running`);
570
+ if (def.kind !== "wake" && liveLocks(wsList) >= reg.maxConcurrent) throw scheduleError("E_SCHEDULER_BUSY", `host concurrency ${reg.maxConcurrent} reached`);
571
+ const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id });
572
+ const after = readState(ws);
573
+ if (after.jobs[id]) { after.jobs[id].lastAttemptedMinute = minuteKey(minuteStart(now)); writeState(ws, after); }
574
+ return { schedule: describe(ws, id, io), run: after.jobs[id]?.lastRun || null, considered };
575
+ });
576
+ }
577
+
578
+ /** Resolve an attempt whose result was never recorded, under the host lock:
579
+ * a spawn attempt adopts the instance named for its minute (deterministic
580
+ * identity), a command attempt only the instance its answer NAMED. Nothing
581
+ * is inferred from file times. An unnamed command effect stays unknown
582
+ * until the operator has checked by hand and passes `clear`. */
583
+ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {}) {
584
+ const defs = readDefinitions(ws);
585
+ const def = defs.jobs[id];
586
+ if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
587
+ return withHostLock(() => {
588
+ const st = readState(ws);
589
+ const js = st.jobs[id] || (st.jobs[id] = {});
590
+ const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt } : undefined);
591
+ if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
592
+ let adopted, ambiguous;
593
+ if (def.kind === "spawn" && attempt.scheduledFor) {
594
+ const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
595
+ const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
596
+ if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
597
+ else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
598
+ } else if (def.kind === "command") {
599
+ const named = js.lastRun?.instance ? findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home })) : [];
600
+ if (named.length === 1) adopted = named[0];
601
+ else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
602
+ else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven absent from here (its answer named no instance); check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
603
+ }
604
+ if (ambiguous && !clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
605
+ if (ambiguous) adopted = undefined;
606
+ delete js.attempt;
607
+ if (adopted) {
608
+ const seen = observeHome(adopted.home, io);
609
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: def.kind, launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
610
+ if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
611
+ } else {
612
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: def.kind, launched: false, outcome: "launch-failed", error: clear ? "cleared by the operator after checking by hand" : "no instance found in the scope's roster for the unrecorded attempt" };
613
+ releaseJobLock(ws, id);
614
+ }
615
+ writeState(ws, st);
616
+ return { schedule: describe(ws, id, io), reconciled: adopted ? "adopted" : "cleared" };
617
+ });
618
+ }
619
+
620
+ // -------------------------------------------------------- definitions CRUD
621
+
622
+ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
623
+ defs ||= readDefinitions(ws); st ||= readState(ws);
624
+ const def = defs.jobs[id];
625
+ if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
626
+ const js = st.jobs[id] || {};
627
+ let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
628
+ return { ...def, nextRun, lastRun: js.lastRun || null, running: !!jobLockInfo(ws, id), ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
629
+ }
630
+ export function listSchedules(ws, io, { now = new Date() } = {}) {
631
+ const defs = readDefinitions(ws), st = readState(ws);
632
+ return { schedules: Object.keys(defs.jobs).sort().map((id) => describe(ws, id, io, { defs, st, now })), scheduler: schedulerStatus(ws, io) };
633
+ }
634
+ export function addSchedule(ws, spec, io) {
635
+ const def = validateDefinition(ws, spec);
636
+ return withScopeLock(ws, () => {
637
+ const defs = readDefinitions(ws);
638
+ if (defs.jobs[def.id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${def.id} already exists in ${ws}`);
639
+ const now = new Date().toISOString();
640
+ defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
641
+ writeDefinitions(ws, defs);
642
+ return describe(ws, def.id, io);
643
+ });
644
+ }
645
+ /** Update under the host lock (so the busy check cannot race a tick's
646
+ * admission) and the scope lock. What a run is tracked or reconciled by
647
+ * (kind, agent, agentsRoot, repo, purpose, home, cwd, argv) cannot change
648
+ * while the job holds a slot or has an unresolved attempt; timing and text can. */
649
+ export function updateSchedule(ws, id, spec, io) {
650
+ const def = validateDefinition(ws, { ...spec, id });
651
+ return withHostLock(() => withScopeLock(ws, () => {
652
+ const defs = readDefinitions(ws);
653
+ if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
654
+ const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv]);
655
+ const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
656
+ if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its kind, agent, agentsRoot, repo, purpose, home, cwd and argv cannot change until it ends (cron, tz, task, message, runtime, model and enabled can)`);
657
+ defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
658
+ writeDefinitions(ws, defs);
659
+ return describe(ws, id, io);
660
+ }), { retryMs: 3000 });
661
+ }
662
+ export function setEnabled(ws, id, enabled, io) {
663
+ return withScopeLock(ws, () => {
664
+ const defs = readDefinitions(ws);
665
+ if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
666
+ defs.jobs[id] = { ...defs.jobs[id], enabled, updatedAt: new Date().toISOString() };
667
+ writeDefinitions(ws, defs);
668
+ return describe(ws, id, io);
669
+ });
670
+ }
671
+ /** Remove under the host lock (state changes under a live tick are refused
672
+ * by the lock, not raced). */
673
+ export function removeSchedule(ws, id, { force = false } = {}) {
674
+ return withHostLock(() => withScopeLock(ws, () => {
675
+ const defs = readDefinitions(ws);
676
+ if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
677
+ if (jobLockInfo(ws, id) && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running; wait for it to end, or --force forgets the job without stopping anything`);
678
+ delete defs.jobs[id];
679
+ writeDefinitions(ws, defs);
680
+ const st = readState(ws); delete st.jobs[id]; writeState(ws, st);
681
+ releaseJobLock(ws, id);
682
+ return { removed: id };
683
+ }));
684
+ }
685
+ /** Save the wake job for a freshly spawned instance (id wake-<instance>). */
686
+ export function saveWakeForHome(ws, { instance, home, wake }) {
687
+ const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
688
+ const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
689
+ const def = validateDefinition(ws, spec);
690
+ return withScopeLock(ws, () => {
691
+ const defs = readDefinitions(ws);
692
+ if (defs.jobs[id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${id} already exists in ${ws}`);
693
+ const now = new Date().toISOString();
694
+ defs.jobs[id] = { ...def, createdAt: now, updatedAt: now };
695
+ writeDefinitions(ws, defs);
696
+ return describe(ws, id);
697
+ });
698
+ }
699
+ /** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
700
+ export function removeWakeForHome(ws, home) {
701
+ if (!existsSync(definitionsPath(ws))) return [];
702
+ // A retirement waits briefly for a running tick rather than failing.
703
+ return withHostLock(() => withScopeLock(ws, () => {
704
+ const defs = readDefinitions(ws);
705
+ const gone = [];
706
+ for (const [id, def] of Object.entries(defs.jobs)) if (def.kind === "wake" && resolve(def.home) === resolve(home)) { delete defs.jobs[id]; gone.push(id); releaseJobLock(ws, id); }
707
+ if (gone.length) { writeDefinitions(ws, defs); const st = readState(ws); for (const id of gone) delete st.jobs[id]; writeState(ws, st); }
708
+ return gone;
709
+ }), { retryMs: 10000 });
710
+ }
711
+ /** Translate the human spawn flags into the wake object. */
712
+ export function wakeFromFlags({ every, cron, tz, message }) {
713
+ if (every === undefined && cron === undefined) return undefined;
714
+ if (every !== undefined && cron !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "use --wake-every or --wake-cron, not both", { field: "cron" });
715
+ let expr = cron;
716
+ if (every !== undefined) {
717
+ const n = Number(every);
718
+ if (!Number.isInteger(n) || n < 1 || n > 59) throw scheduleError("E_SCHEDULE_INVALID", "--wake-every: whole minutes from 1 to 59 (cadence is cron: */N fires at :00 and every N minutes after)", { field: "cron" });
719
+ expr = `*/${n} * * * *`;
720
+ }
721
+ const zone = tz || Intl.DateTimeFormat().resolvedOptions().timeZone;
722
+ if (message === undefined) throw scheduleError("E_SCHEDULE_INVALID", "a wake schedule needs --wake-message or --wake-message-file", { field: "message" });
723
+ return { cron: expr, tz: zone, message, enabled: true };
724
+ }
725
+ export function schedulerStatus(ws, io) {
726
+ const reg = readRegistry();
727
+ const host = readHostState();
728
+ let unit = { installed: false, active: false };
729
+ try { unit = (io?.hostStatus || (() => ({ installed: false, active: false, unavailable: true })))(); } catch (e) { unit = { installed: false, active: false, error: e.message }; }
730
+ return { installed: !!unit.installed, active: !!unit.active, ...(unit.unit ? { unit: unit.unit } : {}), ...(unit.error ? { error: unit.error } : {}), lastTick: host.lastTick || null, maxConcurrent: reg.maxConcurrent, tickIntervalSec: reg.tickIntervalSec, ...(ws ? { workspace: resolve(ws), registered: reg.workspaces.includes(resolve(ws)) } : {}), workspaces: reg.workspaces, live: liveLocks(reg.workspaces.filter((w) => existsSync(w))) };
731
+ }