@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/schedule.mjs
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* observed (launched, active, ended, stopped, launch-failed, unknown;
|
|
20
20
|
* delivered, started, skipped for wake) and never claim that a task
|
|
21
21
|
* succeeded. A launch whose side effects cannot be confirmed stays
|
|
22
|
-
* `unknown
|
|
23
|
-
|
|
22
|
+
* `unknown`, its job blocked until `reconcile` proves what happened; it keeps
|
|
23
|
+
* its host slot too, unless it is a command whose process exit was observed. */
|
|
24
|
+
import { runScheduleCommand } from "./schedule-command.mjs";
|
|
24
25
|
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
25
26
|
import { createHash } from "node:crypto";
|
|
26
27
|
import { homedir } from "node:os";
|
|
@@ -33,12 +34,12 @@ import { herdrSettingRemoved } from "./errors.mjs";
|
|
|
33
34
|
import { withDirLock as withSharedDirLock } from "./dir-lock.mjs";
|
|
34
35
|
import { tickTriggers } from "./triggers.mjs";
|
|
35
36
|
import { resolveMemberClone } from "./instance-resolution.mjs";
|
|
36
|
-
import {
|
|
37
|
-
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
37
|
+
import { SCHEDULE_NAME_RE, readSnapshot, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
38
|
+
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, shq, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
38
39
|
|
|
39
40
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
40
41
|
export const SCHEDULE_API = 2;
|
|
41
|
-
const ID_RE =
|
|
42
|
+
const ID_RE = SCHEDULE_NAME_RE;
|
|
42
43
|
const HARNESSES = new Set(["pi", "claude", "codex"]);
|
|
43
44
|
const MESSAGE_MAX = 256 * 1024;
|
|
44
45
|
const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
|
|
@@ -116,7 +117,7 @@ export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
|
|
|
116
117
|
* O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
|
|
117
118
|
* within budget (an over-budget file is REFUSED as a typed error, never
|
|
118
119
|
* truncated into fabricated JSON). Returns { value, source }. */
|
|
119
|
-
function readJsonBounded(path, fallback, label) {
|
|
120
|
+
function readJsonBounded(path, fallback, label, { atomicReplace = false } = {}) {
|
|
120
121
|
let st;
|
|
121
122
|
try { st = lstatSync(path); }
|
|
122
123
|
catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
|
|
@@ -126,7 +127,10 @@ function readJsonBounded(path, fallback, label) {
|
|
|
126
127
|
try {
|
|
127
128
|
fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
128
129
|
const fst = fstatSync(fd);
|
|
129
|
-
|
|
130
|
+
// The registry is atomically replaced by concurrent writers. Its opened,
|
|
131
|
+
// no-follow regular-file descriptor is a coherent old/new snapshot even
|
|
132
|
+
// when rename changed the inode since lstat. Other readers pin identity.
|
|
133
|
+
if (!fst.isFile() || (!atomicReplace && (fst.dev !== st.dev || fst.ino !== st.ino)) || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
|
|
130
134
|
const buf = Buffer.alloc(fst.size); let got = 0;
|
|
131
135
|
while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
|
|
132
136
|
text = buf.toString("utf8"); st = fst;
|
|
@@ -204,22 +208,73 @@ export function writeState(ws, st) { recordRecentRuns(st); writeJson(statePath(w
|
|
|
204
208
|
|
|
205
209
|
// ------------------------------------------------------- host registry
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
const DEFAULT_MAX_CONCURRENT = 5;
|
|
212
|
+
const scheduleCap = (reg) => reg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
213
|
+
|
|
214
|
+
/** Decode a bounded atomic-file snapshot without locks or filesystem writes. */
|
|
215
|
+
function readRegistryDocument() {
|
|
216
|
+
const path = join(hostScheduleDir(), "registry.json");
|
|
217
|
+
const reg = readJsonBounded(path, { version: 1, capsVersion: 2, tickIntervalSec: 60, workspaces: [] }, "registry.json", { atomicReplace: true }).value;
|
|
218
|
+
if (!reg || typeof reg !== "object" || Array.isArray(reg)) throw scheduleError("E_SCHEDULE_INVALID", `${path} must contain a registry object`, { field: "file" });
|
|
219
|
+
return reg;
|
|
220
|
+
}
|
|
221
|
+
const validCap = (value) => Number.isSafeInteger(value) && value >= 1;
|
|
222
|
+
/** Project legacy defaults in memory. Only locked mutations persist this. */
|
|
223
|
+
function normalizeRegistry(reg) {
|
|
224
|
+
const path = join(hostScheduleDir(), "registry.json");
|
|
209
225
|
if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
|
|
210
|
-
if (
|
|
226
|
+
if (reg.maxConcurrent !== undefined && !validCap(reg.maxConcurrent)) {
|
|
227
|
+
throw scheduleError("E_SCHEDULE_INVALID", `${path}: maxConcurrent must be a positive safe integer, or absent for the default (got ${JSON.stringify(reg.maxConcurrent)}); correct it with oats schedule host install --max-concurrent <N|default> --dir <deployment>`, { field: "maxConcurrent" });
|
|
228
|
+
}
|
|
211
229
|
if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
230
|
+
// Trigger and schedule caps are independent; absent trigger cap is unbounded.
|
|
231
|
+
if (reg.triggersMaxConcurrent !== undefined && !validCap(reg.triggersMaxConcurrent)) {
|
|
232
|
+
throw scheduleError("E_SCHEDULE_INVALID", `${path}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
|
|
233
|
+
}
|
|
234
|
+
if ((reg.capsVersion ?? 1) < 2) {
|
|
235
|
+
if (reg.maxConcurrent === 1) delete reg.maxConcurrent;
|
|
236
|
+
reg.capsVersion = 2;
|
|
217
237
|
}
|
|
218
238
|
return reg;
|
|
219
239
|
}
|
|
240
|
+
export function readRegistry() { return normalizeRegistry(readRegistryDocument()); }
|
|
241
|
+
/** Raw atomic write: no normalization or locking. Read-modify-write callers
|
|
242
|
+
* must hold registry.lock and reread there; readers never persist migration. */
|
|
220
243
|
export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
|
|
221
|
-
|
|
222
|
-
|
|
244
|
+
function updateRegistry(change, caps = {}) {
|
|
245
|
+
return withRegistryLock(() => {
|
|
246
|
+
const raw = readRegistryDocument(), before = JSON.stringify(raw);
|
|
247
|
+
const legacyOne = (raw.capsVersion ?? 1) < 2 && raw.maxConcurrent === 1;
|
|
248
|
+
// A validated explicit replacement can repair an invalid stored schedule
|
|
249
|
+
// cap; an ordinary read or unrelated write must refuse it, never default.
|
|
250
|
+
if (caps.maxConcurrent !== undefined && !validCap(raw.maxConcurrent)) delete raw.maxConcurrent;
|
|
251
|
+
const reg = normalizeRegistry(raw);
|
|
252
|
+
for (const [field, reset] of [["maxConcurrent", "default"], ["triggersMaxConcurrent", "none"]]) {
|
|
253
|
+
if (caps[field] === reset) delete reg[field];
|
|
254
|
+
else if (caps[field] !== undefined) reg[field] = caps[field];
|
|
255
|
+
}
|
|
256
|
+
change(reg);
|
|
257
|
+
// Compare with the raw snapshot so even unchanged membership persists the
|
|
258
|
+
// migration, without a separate write or a stale pre-lock read.
|
|
259
|
+
if (JSON.stringify(reg) !== before) {
|
|
260
|
+
writeRegistry(reg);
|
|
261
|
+
if (legacyOne && (caps.maxConcurrent === undefined || caps.maxConcurrent === "default")) {
|
|
262
|
+
process.stderr.write("oats: warning: migrated legacy maxConcurrent: 1 to the default of 5; an old explicit 1 cannot be distinguished from the old implicit default. To restore one slot, run oats schedule host install --max-concurrent 1 --dir <deployment>. Stop older oats binaries from writing this shared registry.\n");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return reg;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
export function registerWorkspace(ws, caps = {}) {
|
|
269
|
+
for (const [field, reset] of [["maxConcurrent", "default"], ["triggersMaxConcurrent", "none"]]) {
|
|
270
|
+
const value = caps[field];
|
|
271
|
+
if (value !== undefined && value !== reset && !validCap(value)) {
|
|
272
|
+
throw scheduleError("E_BAD_ARGS", `${field} must be a positive safe integer or ${reset}`, { field });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return updateRegistry((reg) => { const abs = resolve(ws); if (!reg.workspaces.includes(abs)) reg.workspaces.push(abs); }, caps);
|
|
276
|
+
}
|
|
277
|
+
export function unregisterWorkspace(ws) { return updateRegistry((reg) => { reg.workspaces = reg.workspaces.filter((w) => w !== resolve(ws)); }); }
|
|
223
278
|
export function readHostState() { return readJson(join(hostScheduleDir(), "state.json"), {}); }
|
|
224
279
|
export function writeHostState(st) { writeJson(join(hostScheduleDir(), "state.json"), st); }
|
|
225
280
|
|
|
@@ -268,6 +323,11 @@ export function validateCron(cron, tz, field = "cron") {
|
|
|
268
323
|
try { return new Cron(cron.trim(), { timezone: tz, paused: true }); }
|
|
269
324
|
catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${field}: ${e.message}`, { field }); }
|
|
270
325
|
}
|
|
326
|
+
/** A definition's `description`: a label for people (list, show, the Desktop), never read by a run. */
|
|
327
|
+
const DESCRIPTION_MAX = 200;
|
|
328
|
+
function validateDescription(description) {
|
|
329
|
+
if (typeof description !== "string" || !description.length || [...description].length > DESCRIPTION_MAX || /[\p{Cc}\u2028\u2029]/u.test(description)) throw scheduleError("E_SCHEDULE_INVALID", `description: one line of 1 to ${DESCRIPTION_MAX} characters, without control characters`, { field: "description" });
|
|
330
|
+
}
|
|
271
331
|
function validateMessage(message, field) {
|
|
272
332
|
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 });
|
|
273
333
|
}
|
|
@@ -286,11 +346,12 @@ export function validateDefinition(ws, def, { checkAgent = true, backendSource }
|
|
|
286
346
|
if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
|
|
287
347
|
if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_INVALID", "kind: a trigger is added with `oats trigger add`, not `oats schedule add`", { field: "kind" });
|
|
288
348
|
const id = def.id;
|
|
289
|
-
if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to
|
|
349
|
+
if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to 100 characters", { field: "id" });
|
|
290
350
|
const enabled = def.enabled === undefined ? true : def.enabled;
|
|
291
351
|
if (typeof enabled !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "enabled: boolean", { field: "enabled" });
|
|
292
352
|
validateCron(def.cron, def.tz);
|
|
293
353
|
const out = { id, enabled, cron: def.cron.trim(), tz: def.tz.trim(), kind: def.kind };
|
|
354
|
+
if (def.description !== undefined) { validateDescription(def.description); out.description = def.description; }
|
|
294
355
|
if (def.kind === "spawn") {
|
|
295
356
|
if (typeof def.agent !== "string" || !def.agent.trim() || def.agent.trim().startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required (not an option)", { field: "agent" });
|
|
296
357
|
out.agent = def.agent.trim();
|
|
@@ -535,12 +596,14 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
|
535
596
|
writeFileSync(taskFile, opts.task, { mode: 0o600 });
|
|
536
597
|
argv.push(`--task-file=${taskFile}`);
|
|
537
598
|
let r;
|
|
538
|
-
try { r =
|
|
599
|
+
try { r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
|
|
539
600
|
finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
|
|
540
|
-
const
|
|
601
|
+
const interrupted = r.interrupted === true;
|
|
602
|
+
const timedOut = r.error?.code === "ETIMEDOUT" || (!interrupted && r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
|
|
541
603
|
const envelope = parseEnvelopeText(r.stdout);
|
|
542
604
|
// The child may have created (and launched) a home before dying: unconfirmed, never a confirmed failure.
|
|
543
605
|
// The thrown message reports RETAINED effects so the tick keeps the attempt and its slot for reconcile.
|
|
606
|
+
if (interrupted) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn was interrupted; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
|
|
544
607
|
if (timedOut) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn timed out; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
|
|
545
608
|
if (!envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_UNCONFIRMED", `workspace spawn answered no valid envelope (exit ${r.status ?? r.signal}); its effects could not be confirmed — run oats schedule reconcile${r.stderr ? `: ${String(r.stderr).trim().split("\n").pop()}` : ""}`);
|
|
546
609
|
if (!envelope.ok) {
|
|
@@ -591,25 +654,32 @@ export function parseEnvelopeText(text) {
|
|
|
591
654
|
}
|
|
592
655
|
/** Run one command job: oats-only argv in the job's cwd. A timeout or an
|
|
593
656
|
* unparseable answer means the side effects are unconfirmed: `unknown`,
|
|
594
|
-
* never `launch-failed`.
|
|
657
|
+
* never `launch-failed`. An unconfirmed answer carries `exit` ({status,
|
|
658
|
+
* signal}) when the process's exit was observed. The supervisor sends SIGTERM
|
|
659
|
+
* at the timeout and SIGKILL after the grace, and observes the direct child's
|
|
660
|
+
* exit before returning. `io.command`, `io.oatsBin`, `io.commandTimeoutMs`
|
|
661
|
+
* and `io.commandGraceMs` are test seams; `io.command` counts as exited. */
|
|
595
662
|
function launchCommand(ws, def, io) {
|
|
596
663
|
const argv = def.argv.includes("--json") ? def.argv.slice(1) : [...def.argv.slice(1), "--json"];
|
|
597
|
-
let envelope, timedOut = false, raw = "";
|
|
598
|
-
if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
|
|
664
|
+
let envelope, timedOut = false, interrupted = false, raw = "", exit;
|
|
665
|
+
if (io?.command) { envelope = io.command({ cwd: def.cwd, argv }); exit = { status: null, signal: null }; }
|
|
599
666
|
else {
|
|
600
|
-
const r =
|
|
601
|
-
|
|
667
|
+
const r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
668
|
+
interrupted = r.interrupted === true;
|
|
669
|
+
timedOut = r.error?.code === "ETIMEDOUT" || (!interrupted && r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
|
|
670
|
+
if (r.status !== null || r.signal !== null) exit = { status: r.status, signal: r.signal };
|
|
602
671
|
raw = String(r.stdout || "");
|
|
603
672
|
envelope = parseEnvelopeText(raw);
|
|
604
673
|
}
|
|
605
|
-
|
|
674
|
+
const exited = exit ? { exit } : {};
|
|
675
|
+
// No envelope, a malformed one, a timeout, or supervisor interruption: the process may still have
|
|
606
676
|
// created a home. That is unconfirmed, never a confirmed failure.
|
|
607
|
-
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" };
|
|
677
|
+
if (interrupted || timedOut || !envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") return { kind: "command", launched: false, unconfirmed: true, ...exited, error: interrupted ? "command was interrupted; its side effects are unconfirmed" : timedOut ? "command timed out; its side effects are unconfirmed" : "command answered no valid envelope; its side effects are unconfirmed" };
|
|
608
678
|
const res = envelope?.result || {};
|
|
609
679
|
const instance = typeof res.instance === "string" ? res.instance : undefined;
|
|
610
680
|
let home = typeof res.home === "string" ? res.home : undefined;
|
|
611
681
|
if (instance && !home) { const found = findHomesInScope(ws, instance); if (found.length === 1) home = found[0]; }
|
|
612
|
-
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` };
|
|
682
|
+
if (envelope.ok && instance && !home) return { kind: "command", launched: true, instance, unconfirmed: true, ...exited, error: `the envelope names instance ${instance} but no home for it is in this scope's roster; run oats schedule reconcile once it appears` };
|
|
613
683
|
// A valid ok:false answer that reports an incomplete rollback (a harvest
|
|
614
684
|
// spawn whose compensation could not stop or remove everything), or an
|
|
615
685
|
// operation runner's unconfirmed outcome (timeout, no valid receipt, a
|
|
@@ -619,7 +689,7 @@ function launchCommand(ws, def, io) {
|
|
|
619
689
|
if (!envelope.ok && (UNCONFIRMED_ERROR_CODES.has(envelope.error?.code) || envelope.error?.details?.unconfirmed === true || reportsRetainedEffects(envelope.error?.message))) {
|
|
620
690
|
const partial = envelope.error?.details?.envelope?.result;
|
|
621
691
|
const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
|
|
622
|
-
return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
|
|
692
|
+
return { kind: "command", launched: false, unconfirmed: true, ...exited, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
|
|
623
693
|
}
|
|
624
694
|
// Provenance the recorder has at write time: the answering server (remote
|
|
625
695
|
// spawn) and the launched home's incarnation. Never a transcript.
|
|
@@ -721,7 +791,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
721
791
|
const minute = minuteStart(now);
|
|
722
792
|
const key = minuteKey(minute);
|
|
723
793
|
const registry = reg || readRegistry();
|
|
724
|
-
const bound = registry
|
|
794
|
+
const bound = scheduleCap(registry);
|
|
725
795
|
const wsAll = wsList || [ws];
|
|
726
796
|
const defs = effectiveDefinitions(ws, ctx);
|
|
727
797
|
const st = readState(ws);
|
|
@@ -776,7 +846,11 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
776
846
|
let invalid;
|
|
777
847
|
try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
|
|
778
848
|
record(id, invalid ? "invalid" : "skipped", { reason: invalid ? `admitted attempt is invalid (${invalid.code || "error"}): ${invalid.message}` : "an earlier launch has no recorded result; run oats schedule reconcile", ...(invalid ? { errorCode: invalid.code } : {}) });
|
|
779
|
-
|
|
849
|
+
// Older kernels kept the cause only on lastRun. Adopt it only for this
|
|
850
|
+
// attempt: a crash before recording the result can leave a prior run there.
|
|
851
|
+
const sameRun = js.lastRun?.outcome === "unknown" && js.lastRun.scheduledFor === js.attempt.scheduledFor && js.lastRun.startedAt === js.attempt.startedAt;
|
|
852
|
+
js.attempt.error ??= (sameRun ? js.lastRun.error : null) ?? "launch attempt without a recorded result";
|
|
853
|
+
js.lastRun = { ...(sameRun ? js.lastRun : { kind: def.kind }), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : js.attempt.error, ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor, startedAt: js.attempt.startedAt };
|
|
780
854
|
continue;
|
|
781
855
|
}
|
|
782
856
|
if (def.kind === "wake") {
|
|
@@ -813,8 +887,14 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
813
887
|
run = { kind: def.kind, launched: false, error: e.message, errorCode: e.code, unconfirmed: def.kind !== "spawn" || reportsRetainedEffects(e.message) };
|
|
814
888
|
}
|
|
815
889
|
if (run.unconfirmed) {
|
|
816
|
-
// Side effects unconfirmed: keep the attempt
|
|
817
|
-
|
|
890
|
+
// Side effects unconfirmed: keep the attempt, with its first error; reconcile decides.
|
|
891
|
+
// A command (or operation) whose process exit was observed runs nothing any more,
|
|
892
|
+
// so it frees its host slot; its attempt still blocks the job itself. Anything else
|
|
893
|
+
// (a spawn, a command that threw) keeps the slot.
|
|
894
|
+
const { exit, ...facts } = run;
|
|
895
|
+
js.attempt = { ...js.attempt, error: run.error, ...(exit ? { exited: true, exitStatus: exit.status, exitSignal: exit.signal } : {}) };
|
|
896
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...facts, outcome: "unknown" };
|
|
897
|
+
if (exit) releaseJobLock(ws, id);
|
|
818
898
|
record(id, "unknown", { error: run.error });
|
|
819
899
|
continue;
|
|
820
900
|
}
|
|
@@ -894,7 +974,7 @@ export function runNow(ws, qid, { now = new Date(), io, force = false, ctx = nul
|
|
|
894
974
|
writeState(ws, st);
|
|
895
975
|
if (js.attempt) throw scheduleError("E_SCHEDULE_UNRESOLVED", `schedule ${id} has a launch attempt without a recorded result; run oats schedule reconcile ${id} first`);
|
|
896
976
|
if (def.kind !== "wake" && jobLockInfo(ws, id)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running`);
|
|
897
|
-
if (def.kind !== "wake" && liveLocks(wsList) >= reg
|
|
977
|
+
if (def.kind !== "wake" && liveLocks(wsList) >= scheduleCap(reg)) throw scheduleError("E_SCHEDULER_BUSY", `host concurrency ${scheduleCap(reg)} reached`);
|
|
898
978
|
const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id, ctx });
|
|
899
979
|
const after = readState(ws);
|
|
900
980
|
if (after.jobs[id]) { after.jobs[id].lastAttemptedMinute = minuteKey(minuteStart(now)); writeState(ws, after); }
|
|
@@ -933,8 +1013,7 @@ export function reconcile(ws, qid, { io, now = new Date(), clear = false, ctx =
|
|
|
933
1013
|
if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
|
|
934
1014
|
else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
|
|
935
1015
|
} else if (attemptKind === "command" || attemptKind === "operation") {
|
|
936
|
-
|
|
937
|
-
if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
1016
|
+
const named = namedHomes(ws, js);
|
|
938
1017
|
if (named.length === 1) adopted = named[0];
|
|
939
1018
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
940
1019
|
else if (!clear) return { schedule: describeHere(), reconciled: "unknown", remedy: `the command's effects cannot be proven from a matching admitted receipt; 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}` };
|
|
@@ -955,6 +1034,60 @@ export function reconcile(ws, qid, { io, now = new Date(), clear = false, ctx =
|
|
|
955
1034
|
});
|
|
956
1035
|
}
|
|
957
1036
|
|
|
1037
|
+
/** The homes in the scope of the instance a command attempt's answer named: its only adoptable
|
|
1038
|
+
* receipt. Exactly one is adoptable; none or several leave its effects unproven. */
|
|
1039
|
+
function namedHomes(ws, js) {
|
|
1040
|
+
const instance = js.lastRun?.instance;
|
|
1041
|
+
return instance ? findHomesInScope(ws, instance).map((home) => ({ instance, home })) : [];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** `oats doctor`: every launch attempt without a recorded result (what reconcile acts on: an
|
|
1045
|
+
* attempt, or a lastRun left `unknown`) in the deployment `dep` and in the other scopes this host
|
|
1046
|
+
* ticks, which share its slots. Each is a warning with its age, whether it holds a host slot and
|
|
1047
|
+
* the reconcile command; `--clear` when it is a command whose effects no named home proves.
|
|
1048
|
+
* Reads state only. → { problems, information } */
|
|
1049
|
+
export function unresolvedScheduleAttempts(dep, { now = new Date() } = {}) {
|
|
1050
|
+
const problems = [], information = [];
|
|
1051
|
+
const here = resolve(dep);
|
|
1052
|
+
let scopes = [here];
|
|
1053
|
+
try { scopes = [here, ...readRegistry().workspaces.filter((w) => resolve(w) !== here && existsSync(w))]; }
|
|
1054
|
+
catch (e) { information.push(`the host schedule registry could not be read, so only this deployment's schedules were checked: ${e.message}`); }
|
|
1055
|
+
for (const ws of scopes) {
|
|
1056
|
+
let defs, st;
|
|
1057
|
+
try { defs = readDefinitions(ws); st = readState(ws); }
|
|
1058
|
+
catch (e) { information.push(`the schedules of ${ws} could not be read: ${e.message}`); continue; }
|
|
1059
|
+
// Read the last discovery snapshot directly: placement and refresh would
|
|
1060
|
+
// require credentials/network, and unresolved work matters even when offline.
|
|
1061
|
+
const workspaceKinds = new Map((readSnapshot(ws)?.schedules || []).map((entry) => [entry.id, entry.definition?.kind]));
|
|
1062
|
+
for (const [key, js] of Object.entries(st.jobs)) {
|
|
1063
|
+
if (!js?.attempt && js?.lastRun?.outcome !== "unknown") continue;
|
|
1064
|
+
// A workspace schedule's state key is <member>~<id> (scheduleStateKey); a local one is its id.
|
|
1065
|
+
const workspace = key.includes("~");
|
|
1066
|
+
if (!workspace && !defs.jobs[key]) continue;
|
|
1067
|
+
const id = workspace ? key.replace("~", "/") : key;
|
|
1068
|
+
const kind = (workspace ? workspaceKinds.get(id) : defs.jobs[key]?.kind) ?? js.lastRun?.kind ?? null;
|
|
1069
|
+
const scheduledFor = js.attempt?.scheduledFor ?? js.lastRun?.scheduledFor ?? null;
|
|
1070
|
+
const startedAt = js.attempt?.startedAt ?? js.lastRun?.startedAt ?? null;
|
|
1071
|
+
const started = Date.parse(startedAt);
|
|
1072
|
+
const ageSeconds = Number.isNaN(started) ? null : Math.max(0, Math.floor((now.getTime() - started) / 1000));
|
|
1073
|
+
const holdsSlot = !!jobLockInfo(ws, key);
|
|
1074
|
+
const error = js.attempt?.error ?? js.lastRun?.error ?? null;
|
|
1075
|
+
const unproven = (kind === "command" || kind === "operation") && namedHomes(ws, js).length !== 1;
|
|
1076
|
+
const dir = ws === here ? "" : ` --dir ${/^[\w./@%+=:,-]+$/.test(ws) ? ws : shq(ws)}`;
|
|
1077
|
+
const remedy = `oats schedule reconcile ${id}${unproven ? " --clear" : ""}${dir}`;
|
|
1078
|
+
const message = `schedule ${id}${ws === here ? "" : ` (in ${ws})`}: ${kind ? `a ${kind}` : "a"} launch attempt for ${scheduledFor} has no recorded result (started ${startedAt}${ageSeconds === null ? "" : `, ${formatAge(ageSeconds)} ago`})${error ? `: ${error}` : ""}. The job does not run again until it is reconciled, and ${holdsSlot ? "it holds a host slot (counted against maxConcurrent)" : "it holds no host slot"}. ${unproven ? `Its effects can't be proven: check the roster and the host by hand, then run ${remedy}` : `Run ${remedy}`}`;
|
|
1079
|
+
problems.push({ code: "schedule-unresolved", severity: "warning", scope: ws, id, kind, scheduledFor, startedAt, ageSeconds, holdsSlot, exited: js.attempt?.exited === true, error, remedy, message });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { problems, information };
|
|
1083
|
+
}
|
|
1084
|
+
function formatAge(s) {
|
|
1085
|
+
if (s < 60) return `${s}s`;
|
|
1086
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
1087
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
1088
|
+
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
958
1091
|
// -------------------------------------------------------- definitions CRUD
|
|
959
1092
|
|
|
960
1093
|
export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -985,7 +1118,8 @@ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {})
|
|
|
985
1118
|
if (def.launchConfig) argv.push(`--launch-config=${def.launchConfig}`);
|
|
986
1119
|
if (def.harness) argv.push(`--harness=${def.harness}`);
|
|
987
1120
|
if (def.model) argv.push(`--model=${def.model}`);
|
|
988
|
-
const r =
|
|
1121
|
+
const r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
1122
|
+
if (r.interrupted === true) throw scheduleError("E_SPAWN_FAILED", "the spawn preview was interrupted; its result is unconfirmed");
|
|
989
1123
|
const envelope = parseEnvelopeText(r.stdout);
|
|
990
1124
|
if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
|
|
991
1125
|
if (!envelope.ok) throw scheduleError(envelope.error?.code || "E_SPAWN_FAILED", String(envelope.error?.message || "spawn preview failed"));
|
|
@@ -1036,7 +1170,7 @@ function projectHistory(js) {
|
|
|
1036
1170
|
export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null } = {}) {
|
|
1037
1171
|
if (typeof qid !== "string" || !qid) throw scheduleError("E_BAD_ARGS", `oats schedule show <id> (local/<id> or <member>/<id>)`);
|
|
1038
1172
|
const { scope, name } = splitId(qid);
|
|
1039
|
-
if (scope !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope) || !
|
|
1173
|
+
if (scope !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope) || !SCHEDULE_NAME_RE.test(name))) throw scheduleError("E_BAD_ARGS", `a workspace schedule id is <member>/<id> (got ${JSON.stringify(qid)})`);
|
|
1040
1174
|
const entry = scope === "local" ? null : (ctx?.schedules || []).find((x) => x.id === `${scope}/${name}`);
|
|
1041
1175
|
if (scope !== "local" && !entry) throw notHere(ws, `${scope}/${name}`, ctx);
|
|
1042
1176
|
if (entry && !entry.placement.runsHere) return scheduleRow(ws, entry, { ...(entry.definition || {}), scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, nextRun: null, lastRun: null, running: false }, ctx);
|
|
@@ -1056,7 +1190,7 @@ export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null }
|
|
|
1056
1190
|
const { workspace: _w, ...stored } = def; void _w;
|
|
1057
1191
|
const base = { ...stored, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
|
|
1058
1192
|
const herdr = entry ? null : storedHerdrBackend(ws, id, def);
|
|
1059
|
-
return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws, ...(herdr ? { invalid: { code: herdr.code, message: herdr.message, field: herdr.field } } : {}) }), base, ctx);
|
|
1193
|
+
return scheduleRow(ws, entry ?? { ...localEntry("schedule", { ...def, id: name }, { dep: ws, ...(herdr ? { invalid: { code: herdr.code, message: herdr.message, field: herdr.field } } : {}) }), description: def.description ?? null }, base, ctx);
|
|
1060
1194
|
}
|
|
1061
1195
|
/** A schedule's list row: its stored facts, the fields every trigger and schedule row share
|
|
1062
1196
|
* (lib/automations.mjs baseRow) and the schedule's own: `kind` is its run (spawn, command, wake,
|
|
@@ -1157,7 +1291,7 @@ function localScheduleId(qid, verb) {
|
|
|
1157
1291
|
}
|
|
1158
1292
|
/** Save the wake job for a freshly spawned instance (id wake-<instance>). */
|
|
1159
1293
|
export function saveWakeForHome(ws, { instance, home, wake }) {
|
|
1160
|
-
const id = `wake-${instance}`.slice(0,
|
|
1294
|
+
const id = `wake-${instance}`.slice(0, 100).replace(/-+$/, "");
|
|
1161
1295
|
const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
|
|
1162
1296
|
return addSchedule(ws, spec);
|
|
1163
1297
|
}
|
|
@@ -1192,5 +1326,5 @@ export function schedulerStatus(ws, io) {
|
|
|
1192
1326
|
const host = readHostState();
|
|
1193
1327
|
let unit = { installed: false, active: false };
|
|
1194
1328
|
try { unit = (io?.hostStatus || (() => ({ installed: false, active: false, unavailable: true })))(); } catch (e) { unit = { installed: false, active: false, error: e.message }; }
|
|
1195
|
-
return { installed: !!unit.installed, active: !!unit.active, ...(unit.unit ? { unit: unit.unit } : {}), ...(unit.error ? { error: unit.error } : {}), lastTick: host.lastTick || null, maxConcurrent: reg.
|
|
1329
|
+
return { installed: !!unit.installed, active: !!unit.active, ...(unit.unit ? { unit: unit.unit } : {}), ...(unit.error ? { error: unit.error } : {}), lastTick: host.lastTick || null, maxConcurrent: scheduleCap(reg), triggersMaxConcurrent: reg.triggersMaxConcurrent ?? null, tickIntervalSec: reg.tickIntervalSec, ...(ws ? { workspace: resolve(ws), registered: reg.workspaces.includes(resolve(ws)) } : {}), workspaces: reg.workspaces, live: liveLocks(reg.workspaces.filter((w) => existsSync(w))) };
|
|
1196
1330
|
}
|
package/lib/servers.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { homedir } from "node:os";
|
|
|
23
23
|
import { basename, join, resolve } from "node:path";
|
|
24
24
|
import { parseStrictJson } from "./canonical-json.mjs";
|
|
25
25
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
26
|
+
import { readWaitingClaim } from "./instance-events.mjs";
|
|
26
27
|
import { withDirLock } from "./dir-lock.mjs";
|
|
27
28
|
|
|
28
29
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
@@ -784,6 +785,11 @@ function listSnapshotServers() {
|
|
|
784
785
|
* derived here. */
|
|
785
786
|
export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
|
|
786
787
|
const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
|
|
788
|
+
/** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
|
|
789
|
+
* key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
|
|
790
|
+
* omits it, and absent ("not reported") must stay distinct from null ("no claim"). The
|
|
791
|
+
* value passes the kernel's own read rule again; anything it refuses is null. */
|
|
792
|
+
const rowWaiting = (i) => (Object.hasOwn(i, "waitingOnYou") ? { waitingOnYou: readWaitingClaim(i.waitingOnYou) } : {});
|
|
787
793
|
|
|
788
794
|
export function rosterGroups({ server, io = {} } = {}) {
|
|
789
795
|
const started = Date.now();
|
|
@@ -841,7 +847,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
841
847
|
harness: i.harness || i.runtime || null, backend: i.tmux ? "tmux" : null,
|
|
842
848
|
...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
|
|
843
849
|
running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
|
|
844
|
-
...rowFacts(i),
|
|
850
|
+
...rowFacts(i), ...rowWaiting(i),
|
|
845
851
|
// Every row the host reports is addressed by its home, saved
|
|
846
852
|
// route or not; savedRoute says only that it was spawned from here.
|
|
847
853
|
retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, addressable: true, missingRemotely: false,
|
|
@@ -1072,8 +1078,20 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
|
|
|
1072
1078
|
io = { ...io, serverId };
|
|
1073
1079
|
const server = io.server || getServer(serverId);
|
|
1074
1080
|
const target = targetOf(server);
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1081
|
+
const setsHostCaps = oatsArgs[0] === "host" && oatsArgs[1] === "install" && oatsArgs.some((arg) =>
|
|
1082
|
+
["--max-concurrent", "--triggers-max-concurrent"].some((name) => arg === name || arg.startsWith(`${name}=`)));
|
|
1083
|
+
const capsUnsupported = () => serverError("E_REMOTE_INCOMPATIBLE", `remote oats at ${target.sshHost} (${serverId}) does not confirm required features schedule and schedule-host-caps; upgrade oats on that destination before setting or resetting host caps`);
|
|
1084
|
+
let remote;
|
|
1085
|
+
try {
|
|
1086
|
+
remote = checkRemote(target, io);
|
|
1087
|
+
if (!remote.features.includes("schedule")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise schedules; upgrade it there`);
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
// Unknown probe formats cannot establish cap support. Keep transport
|
|
1090
|
+
// failures distinct, and preserve the existing contract for no-cap calls.
|
|
1091
|
+
if (setsHostCaps && ["E_REMOTE_ENVELOPE", "E_REMOTE_INCOMPATIBLE"].includes(e.code)) throw capsUnsupported();
|
|
1092
|
+
throw e;
|
|
1093
|
+
}
|
|
1094
|
+
if (setsHostCaps && !remote.features.includes("schedule-host-caps")) throw capsUnsupported();
|
|
1077
1095
|
if (["add", "update"].includes(oatsArgs[0])) {
|
|
1078
1096
|
const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
|
|
1079
1097
|
if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");
|
package/lib/session-input.mjs
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
5
6
|
|
|
6
7
|
const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
|
|
7
|
-
function tmux(target, args, { exec = execFileSync, input } = {}) {
|
|
8
|
+
function tmux(target, args, { exec = execFileSync, input, timeout = 10000, killSignal } = {}) {
|
|
8
9
|
// launchd does not supply a UTF-8 locale. In that environment tmux replaces
|
|
9
10
|
// the tab delimiters in list-panes output with underscores unless forced.
|
|
10
11
|
return exec("tmux", ["-u", "-S", target.socket, ...args], {
|
|
11
|
-
encoding: "utf8", input, timeout:
|
|
12
|
+
encoding: "utf8", input, timeout, ...(killSignal ? { killSignal } : {}), maxBuffer: 1024 * 1024,
|
|
12
13
|
stdio: ["pipe", "pipe", "pipe"],
|
|
13
14
|
});
|
|
14
15
|
}
|
|
@@ -49,13 +50,11 @@ export function inspectSessionTarget(target, io) {
|
|
|
49
50
|
}
|
|
50
51
|
return { backend: "tmux", present: dead === "0", state, paneId };
|
|
51
52
|
}
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// whether it changed the bottom of the screen: a byte comparison only, never a
|
|
56
|
-
// reading of any harness's UI, and the pane's text is never acted on.
|
|
53
|
+
// Settling gives a large bracketed paste time before the single Enter. Screen
|
|
54
|
+
// bytes are observations only: neither change nor silence proves acceptance
|
|
55
|
+
// or authorizes another key. Each phase has one monotonic observation budget.
|
|
57
56
|
const SETTLE_FLOOR_MS = 200, SETTLE_MS_PER_KIB = 3, SETTLE_CAP_MS = 2000, SETTLE_POLL_MS = 100;
|
|
58
|
-
const
|
|
57
|
+
const OBSERVE_CAP_MS = 1000, OBSERVE_WAITS_MS = [300, 600];
|
|
59
58
|
const REGION_LINES = 15, HISTORY_LINES = 200;
|
|
60
59
|
const GEOMETRY = "#{pane_width}x#{pane_height}";
|
|
61
60
|
const realSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
@@ -65,17 +64,22 @@ const realSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),
|
|
|
65
64
|
* visible pane with up to 200 lines of history above it; and `size`, the
|
|
66
65
|
* pane's geometry, read before and after the capture in the same tmux call
|
|
67
66
|
* (null when it moved in between). */
|
|
68
|
-
function capturePane(target, paneId, io) {
|
|
67
|
+
function capturePane(target, paneId, io, deadline, now) {
|
|
68
|
+
const timeout = Math.floor(deadline - now());
|
|
69
|
+
if (timeout <= 0) return null;
|
|
69
70
|
try {
|
|
70
71
|
const lines = tmux(target, ["display-message", "-p", "-t", paneId, GEOMETRY, ";",
|
|
71
72
|
"capture-pane", "-p", "-J", "-t", paneId, "-S", `-${HISTORY_LINES}`, ";",
|
|
72
|
-
|
|
73
|
+
// A read-only probe must not outlive its timeout by ignoring TERM.
|
|
74
|
+
"display-message", "-p", "-t", paneId, GEOMETRY], { ...io, timeout, killSignal: "SIGKILL" }).split("\n");
|
|
75
|
+
if (now() >= deadline) return null;
|
|
73
76
|
if (lines.at(-1) === "") lines.pop();
|
|
74
77
|
const first = lines.shift(), last = lines.pop();
|
|
75
78
|
if (!/^\d+x\d+$/.test(first) || !/^\d+x\d+$/.test(last)) return null;
|
|
76
79
|
for (let i = 0; i < lines.length; i++) lines[i] = lines[i].trimEnd();
|
|
77
80
|
while (lines.length && !lines.at(-1)) lines.pop();
|
|
78
|
-
|
|
81
|
+
const look = { size: first === last ? first : null, region: lines.slice(-REGION_LINES).join("\n"), all: lines.join("\n") };
|
|
82
|
+
return now() < deadline ? look : null;
|
|
79
83
|
} catch { return null; }
|
|
80
84
|
}
|
|
81
85
|
const sameLook = (a, b) => a.size === b.size && a.region === b.region;
|
|
@@ -95,58 +99,49 @@ function unchanged(a, b) {
|
|
|
95
99
|
if (!x || !y) return x === y;
|
|
96
100
|
return (x.endsWith(y) || y.endsWith(x)) && flat(b.all).includes(x) && flat(a.all).includes(y);
|
|
97
101
|
}
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
+
/** One literal paste and one Enter. `submitted` means terminal commands
|
|
103
|
+
* succeeded; `verified` means only that a bounded look changed afterward.
|
|
104
|
+
* Neither is model acceptance. Terminal failures throw; observation failures
|
|
105
|
+
* yield unverified, never a refusal or permission to retransmit. */
|
|
102
106
|
export function inputSessionTarget(target, text, io) {
|
|
103
107
|
const state = inspectSessionTarget(target, io);
|
|
104
108
|
if (!state.present || state.state === "shell") throw new Error(`cannot submit input: session is ${state.state}`);
|
|
105
|
-
const sleep = io?.sleep || realSleep, now = io?.now ||
|
|
109
|
+
const sleep = io?.sleep || realSleep, now = io?.now || (() => performance.now());
|
|
110
|
+
const wait = (ms, deadline) => { const remaining = deadline - now(); if (remaining > 0) sleep(Math.min(ms, remaining)); };
|
|
106
111
|
// Bracketed paste preserves multiline input as one user message. No text
|
|
107
112
|
// is evaluated by a shell or interpreted as tmux key names.
|
|
108
113
|
const buffer = `oats-${randomUUID()}`;
|
|
114
|
+
let settleDeadline;
|
|
109
115
|
try {
|
|
110
116
|
tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
|
|
111
117
|
tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
|
|
118
|
+
settleDeadline = now() + SETTLE_CAP_MS;
|
|
112
119
|
} finally {
|
|
113
120
|
try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
|
|
114
121
|
}
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
sleep(SETTLE_POLL_MS);
|
|
123
|
-
const next = capturePane(target, state.paneId, io);
|
|
122
|
+
// The budget starts at paste completion, before cleanup. Cleanup/Enter keep
|
|
123
|
+
// their original command timeouts; only observation probes/sleeps are clipped.
|
|
124
|
+
wait(SETTLE_FLOOR_MS + Math.ceil(Buffer.byteLength(text) / 1024) * SETTLE_MS_PER_KIB, settleDeadline);
|
|
125
|
+
let before = capturePane(target, state.paneId, io, settleDeadline, now);
|
|
126
|
+
while (before !== null && now() < settleDeadline) {
|
|
127
|
+
wait(SETTLE_POLL_MS, settleDeadline);
|
|
128
|
+
const next = capturePane(target, state.paneId, io, settleDeadline, now);
|
|
124
129
|
const settled = next !== null && sameLook(next, before);
|
|
125
130
|
before = next;
|
|
126
131
|
if (settled) break;
|
|
127
132
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
for (let enters = 1; ; enters++) {
|
|
140
|
-
for (const wait of [VERIFY_MS, RESEND_BACKOFF_MS[enters - 1]]) {
|
|
141
|
-
if (wait === undefined) break;
|
|
142
|
-
sleep(wait);
|
|
143
|
-
const after = capturePane(target, state.paneId, io);
|
|
144
|
-
if (after === null) return { ...state, submitted: true, verified: false };
|
|
145
|
-
if (!unchanged(before, after)) return { ...state, submitted: true, verified: true };
|
|
133
|
+
if (now() >= settleDeadline) before = null;
|
|
134
|
+
tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
|
|
135
|
+
const observeDeadline = now() + OBSERVE_CAP_MS;
|
|
136
|
+
if (before !== null) {
|
|
137
|
+
for (const delay of OBSERVE_WAITS_MS) {
|
|
138
|
+
wait(delay, observeDeadline);
|
|
139
|
+
const after = capturePane(target, state.paneId, io, observeDeadline, now);
|
|
140
|
+
if (after === null) break;
|
|
141
|
+
const changed = !unchanged(before, after);
|
|
142
|
+
if (now() >= observeDeadline) break;
|
|
143
|
+
if (changed) return { ...state, submitted: true, verified: true };
|
|
146
144
|
}
|
|
147
|
-
if (enters > RESEND_BACKOFF_MS.length) break;
|
|
148
|
-
enter();
|
|
149
145
|
}
|
|
150
|
-
|
|
151
|
-
return { ...state, submitted: false, verified: true, reason: "enter-not-taken" };
|
|
146
|
+
return { ...state, submitted: true, verified: false };
|
|
152
147
|
}
|