@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/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` with its slot held until `reconcile` proves what happened. */
23
- import { spawnSync } from "node:child_process";
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 { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
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 = /^[a-z0-9-]{1,40}$/;
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
- if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
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
- export function readRegistry() {
208
- const reg = readJson(join(hostScheduleDir(), "registry.json"), { version: 1, maxConcurrent: 1, tickIntervalSec: 60, workspaces: [] });
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 (!Number.isInteger(reg.maxConcurrent) || reg.maxConcurrent < 1) reg.maxConcurrent = 1;
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
- // The host cap on trigger-spawned live instances (re-review B #5): absent = unbounded (each
213
- // trigger's own concurrency.max still applies). Separate from maxConcurrent: a running scheduled
214
- // job never holds a trigger, and trigger homes never hold a schedule. Hand-edited, so refused loudly.
215
- if (reg.triggersMaxConcurrent !== undefined && !(Number.isSafeInteger(reg.triggersMaxConcurrent) && reg.triggersMaxConcurrent >= 1)) {
216
- throw scheduleError("E_SCHEDULE_INVALID", `${join(hostScheduleDir(), "registry.json")}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
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
- 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; }); }
222
- export function unregisterWorkspace(ws) { return withRegistryLock(() => { const reg = readRegistry(); reg.workspaces = reg.workspaces.filter((w) => w !== resolve(ws)); writeRegistry(reg); return reg; }); }
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 40 characters", { field: "id" });
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 = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
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 timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
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`. `io.oatsBin` and `io.commandTimeoutMs` are test seams. */
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 = 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: childEnv() });
601
- timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
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
- // No envelope, a malformed one, or a timeout: the process may still have
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.maxConcurrent || 1;
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
- js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : "launch attempt without a recorded result", ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor };
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 and the slot; reconcile decides.
817
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
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.maxConcurrent) throw scheduleError("E_SCHEDULER_BUSY", `host concurrency ${reg.maxConcurrent} reached`);
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
- let named = [];
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 = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
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) || !AUTOMATION_ID_RE.test(name))) throw scheduleError("E_BAD_ARGS", `a workspace schedule id is <member>/<id> (got ${JSON.stringify(qid)})`);
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, 40).replace(/-+$/, "");
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.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))) };
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 remote = checkRemote(target, io);
1076
- if (!remote.features.includes("schedule")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise schedules; upgrade it there`);
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");
@@ -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: 10000, maxBuffer: 1024 * 1024,
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
- // Submit verification (#562). A harness can swallow an Enter that lands while
53
- // it is still handling a large bracketed paste, leaving the text unsent in its
54
- // input box. So the pane is left to settle before Enter, and Enter is judged by
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 VERIFY_MS = 300, RESEND_BACKOFF_MS = [300, 600]; // at most 3 Enters in total
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
- "display-message", "-p", "-t", paneId, GEOMETRY], io).split("\n");
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
- return { size: first === last ? first : null, region: lines.slice(-REGION_LINES).join("\n"), all: lines.join("\n") };
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
- /** Paste `text` and submit it with Enter. Answers `submitted` (the Enter was
99
- * taken, or sent unjudged) and `verified` (whether the screen comparison
100
- * judged it); `reason: "enter-not-taken"` only with `submitted: false`. A
101
- * failed paste or key send throws; a failed capture never does. */
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 || Date.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
- const enter = () => tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
116
- // Settle: wait a floor scaled by paste size, then until two consecutive
117
- // captures are identical, giving up at the cap. The last capture is A.
118
- const started = now();
119
- sleep(Math.min(SETTLE_CAP_MS, SETTLE_FLOOR_MS + Math.ceil(Buffer.byteLength(text) / 1024) * SETTLE_MS_PER_KIB));
120
- let before = capturePane(target, state.paneId, io);
121
- while (before !== null && now() - started < SETTLE_CAP_MS) {
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
- enter();
129
- // An unreadable pane gets no further keys: the terminal accepted them, unjudged.
130
- if (before === null) return { ...state, submitted: true, verified: false };
131
- // Changed content means the Enter was taken (or the pane moved for an
132
- // unrelated reason, such as a spinner, a clock or a human typing: read as
133
- // taken, no worse than not looking). Unchanged content means it was
134
- // swallowed: any reaction, a dialog included, would have changed it, so a
135
- // resend is safe. A reflow alone, on a pane seen to change size, is not a
136
- // change. The screen is looked at again after each backoff, so an Enter
137
- // taken late is not followed by another. A capture that fails stops the
138
- // retries: no further Enter is sent.
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
- // The text stays in the input box; it is never pasted again.
151
- return { ...state, submitted: false, verified: true, reason: "enter-not-taken" };
146
+ return { ...state, submitted: true, verified: false };
152
147
  }