@awebai/oats 0.42.0 → 0.43.0

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.
@@ -19,7 +19,8 @@
19
19
  * `.git/` or `node_modules/`. One recursive tree listing per member commit serves both kinds.
20
20
  * - THE HEADER: `kind: <fileKind>` + `schemaVersion: 1` (a wrong or missing kind is
21
21
  * E_AUTOMATION_SCHEMA), `id:` or the filename stem, `runsOn` (a host name), `owner` (a GitHub
22
- * account, `<host>/<login>`), `description?`, `enabled?`. A duplicate id within one member and
22
+ * account, `<host>/<login>`), `description?` (out of the one rule, validateDescription, it is a
23
+ * warning and the entry runs without one), `enabled?`. A duplicate id within one member and
23
24
  * one kind is E_AUTOMATION_DUPLICATE.
24
25
  * - PLACEMENT: a host runs one ONLY when `runsOn` is its `host.name` (oats-local.yaml), its
25
26
  * authenticated `gh` account is `owner` AND its `automations.trust` admits it (0.30: both
@@ -59,6 +60,21 @@ const LOCAL = "local";
59
60
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
60
61
  export function automationError(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
61
62
 
63
+ // ------------------------------------------------------------ descriptions
64
+
65
+ /** A trigger's or schedule's `description`: a one-line label for people (list, show, the
66
+ * Desktop), never read by a run. One rule for every kind and level (0.43.0): local schedules
67
+ * and triggers refuse what breaks it; a workspace file header that breaks it is a warning. */
68
+ export const DESCRIPTION_MAX = 200;
69
+ export const DESCRIPTION_RULE = `one line of 1 to ${DESCRIPTION_MAX} characters, without control characters`;
70
+ /** Throws `code` (the kind's own: E_SCHEDULE_INVALID, E_TRIGGER_INVALID) with field `description`
71
+ * when `description` is not one line of 1 to 200 characters (code points) free of \p{Cc}, U+2028
72
+ * and U+2029. */
73
+ export function validateDescription(description, code = "E_AUTOMATION_SCHEMA") {
74
+ if (typeof description !== "string" || !description.length || [...description].length > DESCRIPTION_MAX || /[\p{Cc}\u2028\u2029]/u.test(description)) throw Object.assign(new Error(`description: ${DESCRIPTION_RULE}`), { code, field: "description", details: { field: "description" } });
75
+ return description;
76
+ }
77
+
62
78
  // ------------------------------------------------------------ names
63
79
 
64
80
  /** `local/<id>` for a machine-private trigger or schedule, `<member>/<id>` for a workspace one. */
@@ -94,7 +110,9 @@ export function candidateOf(path, kinds) {
94
110
  }
95
111
 
96
112
  /** Parse one candidate file: the shared header here, the body by the kind's descriptor. The
97
- * definition is not expanded yet. Never throws: → { entry } | { problem } */
113
+ * definition is not expanded yet. Never throws: → { entry, warning? } | { problem }. A header
114
+ * `description` out of the rule is a `warning` (the same shape as a problem): the entry still
115
+ * loads and runs, with `description: null` — a label never stops a job. */
98
116
  export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey, commit }) {
99
117
  const origin = { kind: "workspace", repoKey, path, commit };
100
118
  const problem = (message, field) => ({ problem: { code: "E_AUTOMATION_SCHEMA", kind: desc.kind, repoKey, path: field ? `${path}#/${field}` : path, message } });
@@ -113,12 +131,16 @@ export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey,
113
131
  if (typeof doc.runsOn !== "string" || !HOST_NAME_RE.test(doc.runsOn)) return problem("runsOn: the host name that runs it (oats-local.yaml host.name: lowercase letters, digits and dashes)", "runsOn");
114
132
  const owner = parseOwner(doc.owner);
115
133
  if (!owner) return problem("owner: the GitHub account it acts as, <host>/<login> (e.g. github.com/acme-kb-bot)", "owner");
116
- if (doc.description !== undefined && typeof doc.description !== "string") return problem("description: text", "description");
117
134
  if (doc.enabled !== undefined && typeof doc.enabled !== "boolean") return problem("enabled: boolean", "enabled");
118
135
  let source;
119
136
  try { source = desc.parseBody(Object.fromEntries(Object.entries(doc).filter(([k]) => !HEADER_KEYS.includes(k)))); }
120
137
  catch (e) { return problem(e.message, e.field); }
121
- return { entry: { kind: desc.kind, id: `${member}/${name}`, name, member, description: doc.description ?? null, runsOn: doc.runsOn, owner: `${owner.host}/${owner.login}`, enabled: doc.enabled !== false, origin, source } };
138
+ let description = null, warning;
139
+ if (doc.description !== undefined) {
140
+ try { description = validateDescription(doc.description); }
141
+ catch { warning = problem(`description: one line of 1 to ${DESCRIPTION_MAX} characters without control characters — shorten it or remove it; the automation keeps running without one`, "description").problem; }
142
+ }
143
+ return { entry: { kind: desc.kind, id: `${member}/${name}`, name, member, description, runsOn: doc.runsOn, owner: `${owner.host}/${owner.login}`, enabled: doc.enabled !== false, origin, source }, ...(warning ? { warning } : {}) };
122
144
  }
123
145
 
124
146
  /** Discover the workspace triggers and schedules of the confirmed members: one tree listing per
@@ -157,6 +179,7 @@ export async function discoverAutomations(discovery, { remote, memberName, kinds
157
179
  catch (e) { problems.push({ code: e?.code || "E_REMOTE_UNREADABLE", kind: c.kind, repoKey: m.key, path, message: e.message }); continue; }
158
180
  const parsed = parseAutomationFile(desc, { stem: c.stem, path, bytes, member: name, repoKey: m.key, commit: m.commit });
159
181
  if (parsed.problem) { problems.push(parsed.problem); continue; }
182
+ if (parsed.warning) problems.push(parsed.warning);
160
183
  const a = parsed.entry;
161
184
  const first = seen[a.kind].get(a.name);
162
185
  if (first) { problems.push({ code: "E_AUTOMATION_DUPLICATE", kind: a.kind, repoKey: m.key, path, message: `${a.kind} id ${JSON.stringify(a.name)} is declared by both ${first} and ${path}; ${path} is not listed` }); continue; }
@@ -387,9 +410,12 @@ export function memberCheckoutFor(dir, desc, id, { sameRepo }) {
387
410
  /** The fields every trigger row and every schedule row share (docs/desktop-cli-api.md): identity,
388
411
  * origin, who and where. Each kind's module adds its own (`kind`, the soul, the task, the event
389
412
  * or the cron, the last and next run). */
413
+ const ruledDescription = (d) => { try { return d === undefined || d === null ? null : validateDescription(d); } catch { return null; } };
390
414
  export function baseRow(a) {
391
415
  return {
392
- id: a.id, name: a.name, origin: a.origin, description: a.description ?? null,
416
+ // The header's (a workspace file), else the definition's (a local one; a package template's),
417
+ // never a stored one that breaks the rule (a hand-edited, invalid definition).
418
+ id: a.id, name: a.name, origin: a.origin, description: a.description ?? ruledDescription(a.definition?.description),
393
419
  owner: a.owner ?? null, runsOn: a.runsOn ?? null,
394
420
  runsHere: a.placement.runsHere, reason: a.placement.reason, ...(a.placement.detail ? { reasonDetail: a.placement.detail } : {}),
395
421
  enabledHere: a.placement.enabledHere,
package/lib/core.mjs CHANGED
@@ -71,7 +71,7 @@ export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
71
71
  import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
72
72
  import { readPortableBytes } from "./bounded-read.mjs";
73
73
  import { copyTreeSafe } from "./tree-copy.mjs";
74
- import { assertSameWorktreeHead, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
74
+ import { assertSameWorktreeHead, gitRead, gitRepoRead, gitRepoRun, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
75
75
  /** The package and capability id grammar (namespaced, lowercase): an id names a
76
76
  * directory (a home's module copy), so no path spelling fits it. */
77
77
  const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -5141,18 +5141,123 @@ function baselineDisposableHome(baseline) {
5141
5141
  * that pass all use this one set. → { excludeRoot: Set (work/ and the covered
5142
5142
  * names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
5143
5143
  * and owners only. Two owners of one entry: the first in owner order. */
5144
- function resolveHomeExclusions(home, disposableHome = []) {
5144
+ function resolveHomeExclusions(home, disposableHome = [], extraTrees = []) {
5145
5145
  const excludeRoot = new Set(["work"]);
5146
5146
  const notCopied = [];
5147
- if (!disposableHome.length) return { excludeRoot, notCopied };
5147
+ const extra = new Set(extraTrees.map((t) => t.name));
5148
+ if (!disposableHome.length && !extra.size) return { excludeRoot, notCopied };
5148
5149
  for (const name of readdirSync(home).sort(byCodeUnit)) {
5149
- const row = name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name));
5150
- if (!row) continue;
5150
+ const owner = extra.has(name) ? EXTRA_WORKTREE_OWNER : name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name))?.owner;
5151
+ if (!owner) continue;
5151
5152
  excludeRoot.add(name);
5152
- notCopied.push({ scope: "home", path: name, owner: row.owner });
5153
+ notCopied.push({ scope: "home", path: name, owner });
5153
5154
  }
5154
5155
  return { excludeRoot, notCopied };
5155
5156
  }
5157
+
5158
+ /** The `notCopied` owner of a verified extra tree: the retire's extra-tree
5159
+ * step handles it, not the home recovery. */
5160
+ const EXTRA_WORKTREE_OWNER = "kernel:extra-worktree";
5161
+ /** `git worktree list --porcelain -z` → [{ worktree, locked }]: `locked` is
5162
+ * the lock's reason ("" when it gives none), undefined when not locked. */
5163
+ function parseWorktreeList(out) {
5164
+ const records = [];
5165
+ for (const field of out.split("\0")) {
5166
+ if (field.startsWith("worktree ")) records.push({ worktree: field.slice("worktree ".length) });
5167
+ else if (records.length && (field === "locked" || field.startsWith("locked "))) records.at(-1).locked = field.slice("locked ".length);
5168
+ }
5169
+ return records;
5170
+ }
5171
+ /** The home's extra trees (awebai/oats#674), in every work mode: top-level
5172
+ * entries named `.work-*` that are real directories (not symlinks), whose
5173
+ * `.git` is a regular file, and that Git confirms are registered linked
5174
+ * worktrees of a repository outside the home: the git dir differs from the
5175
+ * common dir, the toplevel is the entry, and the repository's worktree list
5176
+ * names it. The repository is that list's first entry (its main worktree, or
5177
+ * the bare dir), as for the primary checkout (canonicalDeploymentPath).
5178
+ * Anything else named `.work-*` is ordinary home bytes. Read-only probes
5179
+ * (gitRead). → [{ name, path, repo, gitDir, commonDir, locked }] sorted by
5180
+ * name: `commonDir` is the repository's Git directory, which the step and
5181
+ * the reachability read name it by (bare or not). */
5182
+ function extraWorktreesOf(home) {
5183
+ const trees = [];
5184
+ let names;
5185
+ try { names = readdirSync(home); } catch { return trees; }
5186
+ const realHome = realPathOrNearest(home);
5187
+ const inHome = (p) => { const rel = relative(realHome, realPathOrNearest(p)); return rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)); };
5188
+ for (const name of names.sort(byCodeUnit)) {
5189
+ if (!name.startsWith(".work-")) continue;
5190
+ const path = join(home, name);
5191
+ try {
5192
+ if (!lstatSync(path).isDirectory() || !lstatSync(join(path, ".git")).isFile()) continue;
5193
+ } catch { continue; }
5194
+ const dirs = gitRead(path, ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel"]);
5195
+ if (!dirs.ok) continue;
5196
+ const [gitDir, commonDir, toplevel] = dirs.out.split("\n");
5197
+ if (!gitDir || !commonDir || !toplevel || realPathOrNearest(gitDir) === realPathOrNearest(commonDir)) continue;
5198
+ const real = realPathOrNearest(path);
5199
+ // A repository inside the home goes with the home: its trees are home bytes.
5200
+ if (realPathOrNearest(toplevel) !== real || inHome(commonDir)) continue;
5201
+ const list = gitRead(path, ["worktree", "list", "--porcelain", "-z"]);
5202
+ if (!list.ok) continue;
5203
+ const records = parseWorktreeList(list.out);
5204
+ const self = records.find((r) => realPathOrNearest(r.worktree) === real);
5205
+ if (!records.length || !self) continue;
5206
+ trees.push({ name, path, repo: records[0].worktree, gitDir, commonDir: realPathOrNearest(commonDir), locked: self.locked });
5207
+ }
5208
+ return trees;
5209
+ }
5210
+ /** Where a retired worktree is re-homed: `<workspace>/.agents/worktrees/<repoName>/<leaf>`,
5211
+ * the leaf being the branch, else `detached-<commit>`; when that exists (or
5212
+ * `taken` holds it), `<leaf>-2`, `-3`, … The one naming rule of work/ and of
5213
+ * the extra trees. Creates nothing. */
5214
+ function retainedWorktreeDest(workspace, repo, branch, commit, taken = new Set()) {
5215
+ const repoName = basename(realPathOrNearest(repo)).replace(/\.git$/, "") || "repo";
5216
+ const leaf = (branch ?? `detached-${(commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
5217
+ const retainedRoot = join(workspace, ".agents", "worktrees", repoName);
5218
+ let dest = join(retainedRoot, leaf);
5219
+ for (let n = 2; existsSync(dest) || taken.has(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
5220
+ taken.add(dest);
5221
+ return dest;
5222
+ }
5223
+ /** What retirement does with each extra tree (`trees`: extraWorktreesOf) →
5224
+ * [{ path, repo, branch, detachedAt, disposition, movedTo, reason }], one row
5225
+ * per tree in order: the retire plan's `extraWorktrees` and the binding the
5226
+ * retire checks before it acts. `remove` when the tree is clean: an empty
5227
+ * status (ignored and untracked files count), no operation in progress, and
5228
+ * a HEAD commit some ref of the repository reaches: its shared refs, never
5229
+ * the tree's own HEAD, reflog or refs/worktree/, which go with its admin
5230
+ * entry when it is removed. `retain` (to `movedTo`) otherwise, with why
5231
+ * in `reason`; a HEAD that cannot be read is not clean. `refuse` for a locked
5232
+ * tree, which Git will neither move nor remove. Read-only. */
5233
+ function extraWorktreeRows(trees, root) {
5234
+ const taken = new Set();
5235
+ return trees.map((tree) => {
5236
+ let name = null, commit = null;
5237
+ try { name = headName(tree.path); } catch { /* not clean, below */ }
5238
+ try { commit = worktreeHead(tree.path).commit; } catch { /* not clean, below */ }
5239
+ const row = { path: tree.path, repo: tree.repo, branch: name?.branch ?? null, detachedAt: name?.detached ? commit : null, disposition: "remove", movedTo: null, reason: null };
5240
+ if (tree.locked !== undefined) return { ...row, disposition: "refuse", reason: `it is locked${tree.locked ? ` (${tree.locked})` : ""}; unlock it with \`git worktree unlock\`, or move it out of the home, then retire again` };
5241
+ const why = [];
5242
+ if (!name || !commit) why.push("its HEAD could not be read");
5243
+ const status = gitRead(tree.path, ["status", "--porcelain", "-z", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]);
5244
+ if (!status.ok) why.push(`its status could not be read (${status.err})`);
5245
+ else if (status.out.length) why.push("it holds uncommitted, untracked or ignored files");
5246
+ if (RECOVERABLE_GIT_ADMIN.some((n) => existsSync(join(tree.gitDir, n)))) why.push("an operation is in progress in it");
5247
+ if (commit) {
5248
+ const reach = gitRepoRead(tree.commonDir, ["for-each-ref", "--contains", commit, "--count=1", "--format=%(objectname)"]);
5249
+ if (!reach.ok) why.push(`which refs reach its HEAD commit could not be read (${reach.err})`);
5250
+ else if (!reach.out.length) why.push("its HEAD commit is reached by no ref");
5251
+ }
5252
+ if (!why.length) return row;
5253
+ return { ...row, disposition: "retain", movedTo: retainedWorktreeDest(workspaceOf(root), tree.repo, row.branch, commit, taken), reason: why.join("; ") };
5254
+ });
5255
+ }
5256
+ /** The retire plan's view of a home's extra trees: extraWorktreeRows of what
5257
+ * is there now. */
5258
+ export function extraWorktreePlan(home, root) {
5259
+ return extraWorktreeRows(extraWorktreesOf(home), root);
5260
+ }
5156
5261
  function unionNotCopied(...lists) {
5157
5262
  const byPath = new Map();
5158
5263
  for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
@@ -6414,9 +6519,11 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
6414
6519
  }
6415
6520
  const baselineValid = retirementBaselineValid(baseline, home);
6416
6521
  // This pass's one resolved exclusion set: provider-owned entries the spawn
6417
- // baseline declared. Every home fingerprint here and the copy made from this
6418
- // observation use it; without a valid baseline there is none.
6419
- const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : []);
6522
+ // baseline declared, and the home's verified extra trees (whatever the
6523
+ // baseline), which the retire's extra-tree step handles from this same set.
6524
+ // Every home fingerprint here and the copy made from this observation use it.
6525
+ const extraTrees = extraWorktreesOf(home);
6526
+ const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : [], extraTrees);
6420
6527
  // One walk of the home, two digests: the stored one for the comparison with
6421
6528
  // the baseline, which must not see the kernel's own fields of instance.json,
6422
6529
  // and the exact one for the comparison after the hooks, which must see
@@ -6504,7 +6611,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
6504
6611
  .update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
6505
6612
  .update(unreached?.head.ref ?? "")
6506
6613
  .digest("hex");
6507
- return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6614
+ return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, extraTrees, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6508
6615
  }
6509
6616
 
6510
6617
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -7020,7 +7127,8 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
7020
7127
  const intent = {
7021
7128
  instance: name, agent: found.agent.name, root: resolve(root),
7022
7129
  requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
7023
- options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
7130
+ // A confirmed plan's extra trees go with the intent, so the completion is bound by them as well.
7131
+ options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session, ...(Array.isArray(o.plannedExtraWorktrees) ? { plannedExtraWorktrees: o.plannedExtraWorktrees } : {}) }, resultPath,
7024
7132
  };
7025
7133
  const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
7026
7134
  for (const k of CORE_LAUNCH_ENV) delete env[k];
@@ -7098,6 +7206,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
7098
7206
  try {
7099
7207
  result = retireInstance(intent.root, intent.instance, {
7100
7208
  home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
7209
+ ...(Array.isArray(intent.options?.plannedExtraWorktrees) ? { plannedExtraWorktrees: intent.options.plannedExtraWorktrees } : {}),
7101
7210
  ...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
7102
7211
  });
7103
7212
  } catch (e) {
@@ -7241,6 +7350,16 @@ export function retireInstance(root, name, o = {}) {
7241
7350
  // Whether this retire removes the worktree, when it gets to that step.
7242
7351
  const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
7243
7352
  const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
7353
+ // A locked extra tree is known from the home as it is now: Git will neither move nor remove it, so a retire that
7354
+ // would remove the home refuses here, before the session is stopped and before any retire hook runs (the hooks
7355
+ // revoke identities a kept home would still need). The extra-tree step keeps its own check, for a lock that appears
7356
+ // during the hooks. --force does not bypass it: it covers hook debt, not local work.
7357
+ if (!o.keepDir) {
7358
+ const locked = initialObservation.extraTrees.filter((t) => t.locked !== undefined);
7359
+ if (locked.length) {
7360
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${locked.map((t) => `the extra worktree ${t.path} is locked${t.locked ? ` (${t.locked})` : ""}`).join("; ")}; Git will neither move nor remove a locked worktree. Unlock it with \`git worktree unlock\`, or move it out of the home, then retire again; nothing was run or removed`);
7361
+ }
7362
+ }
7244
7363
  // Harness identity is destructive authority. The mutable child metadata may
7245
7364
  // describe it for humans, but only the independent baseline can authorize the
7246
7365
  // endpoint that proves quiescence.
@@ -7484,6 +7603,54 @@ export function retireInstance(root, name, o = {}) {
7484
7603
  // A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
7485
7604
  // --discard-worktree. An orphaned work directory is never touched.
7486
7605
  const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
7606
+ // The home's extra trees (awebai/oats#674), in every work mode, before the
7607
+ // work/ step so that a refusal here leaves work/ as it is. Only when the
7608
+ // home is going to be removed: not under --keep-dir, and not when it is
7609
+ // kept for a retry (the work/ step's condition). The trees are the final
7610
+ // inspection's verified set, the one its recovery left out of the home's
7611
+ // bytes. A clean tree is removed (its branch stays in its repository); any
7612
+ // other is re-homed like work/; a locked one, or one Git will not move or
7613
+ // remove, refuses and keeps the home, --force included: it covers hook
7614
+ // debt, not local work. --discard-worktree does not apply. An applied plan
7615
+ // (`plannedExtraWorktrees`) binds what is done: trees that no longer read
7616
+ // as planned refuse as stale before any of them is touched.
7617
+ let extraWorktrees;
7618
+ if (!o.keepDir && !(outstandingBeforeWorktree.length > 0 && !o.force)) {
7619
+ const rows = extraWorktreeRows(finalObservation.extraTrees, root);
7620
+ if (o.plannedExtraWorktrees && JSON.stringify(rows) !== JSON.stringify(o.plannedExtraWorktrees)) {
7621
+ throw oatsError("E_PLAN_STALE", `${name}: the home's extra worktrees changed since the retire plan was shown, so none of them was moved or removed. The retire hooks have run; the home and its work are kept. Review the fresh plan (\`oats retire ${name} --plan\`) and apply it again.`);
7622
+ }
7623
+ const refused = rows.filter((r) => r.disposition === "refuse");
7624
+ if (refused.length) {
7625
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${refused.map((r) => `the extra worktree ${r.path}: ${r.reason}`).join("; ")}. No extra worktree was moved or removed and work/ is untouched; the retire hooks have run and the home is kept so nothing is lost.`);
7626
+ }
7627
+ extraWorktrees = [];
7628
+ const done = () => extraWorktrees.length ? ` Already done in this retire: ${extraWorktrees.map((r) => r.outcome === "removed" ? `${r.path} removed` : `${r.path} re-homed to ${r.movedTo}`).join(", ")}.` : "";
7629
+ // Each row's repository, by its Git directory: the commands run helper-free (gitRepoRun).
7630
+ const gitDirOf = new Map(finalObservation.extraTrees.map((tree) => [tree.path, tree.commonDir]));
7631
+ const git = (row, argv) => gitRepoRun(gitDirOf.get(row.path), argv);
7632
+ const failed = (row, what, e) => oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the extra worktree ${row.path} ${what} (${String(e?.stderr ?? e?.message ?? e ?? "").trim()}).${done()} work/ is untouched; the retire hooks have run and the home is kept so nothing is lost — resolve and retry.`);
7633
+ for (const row of rows) {
7634
+ if (row.disposition === "remove") {
7635
+ try {
7636
+ git(row, ["worktree", "remove", row.path]);
7637
+ git(row, ["worktree", "prune"]);
7638
+ const real = realPathOrNearest(row.path);
7639
+ if (existsSync(row.path) || parseWorktreeList(git(row, ["worktree", "list", "--porcelain", "-z"])).some((r) => realPathOrNearest(r.worktree) === real)) throw new Error("it is still there after `git worktree remove`");
7640
+ } catch (e) { throw failed(row, "could not be removed, or its removal could not be verified", e); }
7641
+ extraWorktrees.push({ ...row, outcome: "removed" });
7642
+ appendEvent(found.home, { kind: "worktree-removed", data: { branch: row.branch, extra: true, path: row.path } }, { workspaceOnly: true });
7643
+ } else {
7644
+ try {
7645
+ mkdirSync(dirname(row.movedTo), { recursive: true });
7646
+ git(row, ["worktree", "move", row.path, row.movedTo]);
7647
+ } catch (e) { throw failed(row, `could not be re-homed to ${row.movedTo}`, e); }
7648
+ extraWorktrees.push({ ...row, outcome: "retained" });
7649
+ appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: row.movedTo, branch: row.branch, recordedBranch: null, extra: true, path: row.path } }, { workspaceOnly: true });
7650
+ }
7651
+ }
7652
+ if (!extraWorktrees.length) extraWorktrees = undefined;
7653
+ }
7487
7654
  const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
7488
7655
  const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
7489
7656
  const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
@@ -7507,12 +7674,8 @@ export function retireInstance(root, name, o = {}) {
7507
7674
  shTry(`git -C ${shq(meta.repo)} worktree prune`);
7508
7675
  retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
7509
7676
  } else if (existsSync(workPath)) {
7510
- const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
7511
- const leaf = (verifiedBranch ?? `detached-${(ref.commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
7512
- const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
7513
- mkdirSync(retainedRoot, { recursive: true });
7514
- let dest = join(retainedRoot, leaf);
7515
- for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
7677
+ const dest = retainedWorktreeDest(workspaceOf(root), meta.repo, verifiedBranch, ref.commit);
7678
+ mkdirSync(dirname(dest), { recursive: true });
7516
7679
  try {
7517
7680
  execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
7518
7681
  } catch (e) {
@@ -7623,7 +7786,7 @@ export function retireInstance(root, name, o = {}) {
7623
7786
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
7624
7787
  }
7625
7788
 
7626
- const result = { retired: name, agent: found.agent.name, workRecovery, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
7789
+ const result = { retired: name, agent: found.agent.name, workRecovery, retention, extraWorktrees, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
7627
7790
  const w = [...(hookResults?.warnings || [])];
7628
7791
  if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
7629
7792
  if (isCapturedHome(meta) && !quarantine) {
@@ -43,6 +43,28 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
43
43
  }
44
44
  }
45
45
  const trim = (s) => (s === null ? null : s.trim());
46
+ /** One helper-free Git probe run like the module's other reads
47
+ * (READ_ONLY_GIT, gitEnv, argv, no shell) → { ok, out, err }. `out` is text.
48
+ * Never throws: the caller decides what a failure means. `cwd`: the tree
49
+ * `-C` names; gitRepoRead names the repository by its Git directory instead. */
50
+ export function gitRead(cwd, argv) { return gitProbe(["-C", cwd], argv); }
51
+ /** gitRead against the repository whose (common) Git directory is `gitDir`,
52
+ * named explicitly (`--git-dir`), so that a bare repository is read too
53
+ * (READ_ONLY_GIT sets safe.bareRepository=explicit). */
54
+ export function gitRepoRead(gitDir, argv) { return gitProbe(["--git-dir", gitDir], argv); }
55
+ /** A Git command that changes the repository whose Git directory is `gitDir`
56
+ * (a worktree move, remove or prune), under the same discipline as the
57
+ * reads: the repository's own configuration names no helper that runs
58
+ * (fsmonitor, hooks, external diff; they reach the commands Git starts for
59
+ * it through GIT_CONFIG_PARAMETERS), and nothing of the caller's Git
60
+ * environment is passed. → stdout; throws the error execFileSync throws. */
61
+ export function gitRepoRun(gitDir, argv) {
62
+ return execFileSync("git", ["--git-dir", gitDir, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 120_000, env: gitEnv() });
63
+ }
64
+ function gitProbe(where, argv) {
65
+ const r = spawnSync("git", [...where, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
66
+ return !r.error && r.status === 0 ? { ok: true, out: r.stdout } : { ok: false, out: "", err: String(r.error?.message ?? r.stderr ?? "").trim() || `git ${argv[0]} exited with ${r.status}` };
67
+ }
46
68
 
47
69
  const BRANCH_REFS = Buffer.from("refs/heads/");
48
70
  /** The two reads of HEAD, as bytes. They run like the module's other reads:
@@ -10,7 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { basename, join } from "node:path";
13
- import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
13
+ import { extraWorktreePlan, findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
14
14
  import { groupedByOwner } from "./retire-output.mjs";
15
15
  import { observeInstanceGit } from "./instance-git.mjs";
16
16
  import { appendEvent } from "./instance-events.mjs";
@@ -165,6 +165,13 @@ function writeFileSyncAtomic(path, value) {
165
165
  writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
166
166
  }
167
167
 
168
+ /** A retire plan's note for one extra tree (core extraWorktreePlan row). */
169
+ function extraWorktreeNote(t) {
170
+ const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt.slice(0, 12)}` : "a HEAD that could not be read";
171
+ if (t.disposition === "remove") return `extra worktree ${t.path} (${at}) is clean and would be removed; its branch and commits stay in ${t.repo}`;
172
+ if (t.disposition === "retain") return `extra worktree ${t.path} (${at}) would be re-homed to ${t.movedTo}: ${t.reason}`;
173
+ return `extra worktree ${t.path} (${at}): retire refuses, ${t.reason}`;
174
+ }
168
175
  /** How many declared roots a retire plan's recovery note lists before `, and N more`. */
169
176
  const PLAN_NOT_COPIED_CAP = 16;
170
177
  /** Facts for Remove (retire): what retirement would touch, with the design's
@@ -178,12 +185,19 @@ export function planRetire(ctx, root, name, { home } = {}) {
178
185
  const target = targetFacts({ ...me, instance: name, depth: 0 });
179
186
  target.session = retireSessionFacts(me.home, target.session);
180
187
  const meta = readJson(join(me.home, "instance.json")) || {};
188
+ // The home's extra trees and what retire does with each: listed, and bound
189
+ // into the revision (below), so that an apply refuses as stale when a tree
190
+ // was created, removed, dirtied or cleaned, or its target was taken.
191
+ const extraWorktrees = extraWorktreePlan(me.home, me.root);
181
192
  const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
193
+ extraWorktrees,
182
194
  children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
183
195
  ambiguous: (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason })),
184
196
  pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
185
197
  const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
186
- const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
198
+ const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home),
199
+ // Only when there are any: a home without extra trees keeps the revision it had.
200
+ ...(extraWorktrees.length ? [extraWorktrees] : [])];
187
201
  // What retire would preserve and where, read from the spawn baseline and the
188
202
  // work mode: nothing is hashed, and the plan revision does not depend on it.
189
203
  // The declared roots are listed as written, capped so one note stays short.
@@ -195,6 +209,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
195
209
  notes: [
196
210
  ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
197
211
  ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
212
+ ...extraWorktrees.map(extraWorktreeNote),
198
213
  ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
199
214
  ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
200
215
  ...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
@@ -1,6 +1,7 @@
1
1
  /** What `oats retire` says about the work it preserved. One function renders
2
2
  * the lines for the local and the remote path of bin/oats.mjs, from the
3
- * receipt's `workRecovery` (lib/core.mjs, retireInstance). A receipt from an
3
+ * receipt's `workRecovery` (lib/core.mjs, retireInstance); another renders
4
+ * its `extraWorktrees`. A receipt from an
4
5
  * older kernel, or a stored one, may carry `workRecoveries[]` instead: one
5
6
  * block is printed per entry. Dependency-free. */
6
7
 
@@ -48,3 +49,15 @@ export function workRecoveryLines(receipt, { host } = {}) {
48
49
  }
49
50
  return lines;
50
51
  }
52
+
53
+ /** The lines `oats retire` prints for the home's extra trees it handled
54
+ * (the receipt's `extraWorktrees`): one per tree, removed or re-homed. */
55
+ export function extraWorktreeLines(receipt, { host } = {}) {
56
+ const rows = Array.isArray(receipt?.extraWorktrees) ? receipt.extraWorktrees : [];
57
+ return rows.map((t) => {
58
+ const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt}` : "a HEAD that could not be read";
59
+ return t.outcome === "removed"
60
+ ? `Extra worktree ${t.path} removed (${at}); its branch and commits stay in ${t.repo}${host ? ` on ${host}` : ""}`
61
+ : `Extra worktree ${t.path} re-homed${host ? ` on ${host}` : ""} to ${t.movedTo} (${at}): ${t.reason}`;
62
+ });
63
+ }
package/lib/schedule.mjs CHANGED
@@ -34,7 +34,7 @@ import { herdrSettingRemoved } from "./errors.mjs";
34
34
  import { withDirLock as withSharedDirLock } from "./dir-lock.mjs";
35
35
  import { tickTriggers } from "./triggers.mjs";
36
36
  import { resolveMemberClone } from "./instance-resolution.mjs";
37
- import { SCHEDULE_NAME_RE, readSnapshot, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
37
+ import { SCHEDULE_NAME_RE, readSnapshot, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId, validateDescription } from "./automations.mjs";
38
38
  import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, shq, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
39
39
 
40
40
  export const SCHEDULE_FILE = "oats-schedules.json";
@@ -323,11 +323,6 @@ export function validateCron(cron, tz, field = "cron") {
323
323
  try { return new Cron(cron.trim(), { timezone: tz, paused: true }); }
324
324
  catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${field}: ${e.message}`, { field }); }
325
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
- }
331
326
  function validateMessage(message, field) {
332
327
  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 });
333
328
  }
@@ -351,7 +346,8 @@ export function validateDefinition(ws, def, { checkAgent = true, backendSource }
351
346
  if (typeof enabled !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "enabled: boolean", { field: "enabled" });
352
347
  validateCron(def.cron, def.tz);
353
348
  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; }
349
+ // A label for people (list, show, the Desktop), never read by a run (lib/automations.mjs).
350
+ if (def.description !== undefined) out.description = validateDescription(def.description, "E_SCHEDULE_INVALID");
355
351
  if (def.kind === "spawn") {
356
352
  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" });
357
353
  out.agent = def.agent.trim();
@@ -1190,7 +1186,7 @@ export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null }
1190
1186
  const { workspace: _w, ...stored } = def; void _w;
1191
1187
  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 } : {}) };
1192
1188
  const herdr = entry ? null : storedHerdrBackend(ws, id, def);
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);
1189
+ return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws, ...(herdr ? { invalid: { code: herdr.code, message: herdr.message, field: herdr.field } } : {}) }), base, ctx);
1194
1190
  }
1195
1191
  /** A schedule's list row: its stored facts, the fields every trigger and schedule row share
1196
1192
  * (lib/automations.mjs baseRow) and the schedule's own: `kind` is its run (spawn, command, wake,
@@ -1256,6 +1252,23 @@ export function updateSchedule(ws, qid, spec, io) {
1256
1252
  return describe(ws, localId(id), io);
1257
1253
  }), { retryMs: 3000 });
1258
1254
  }
1255
+ /** Change only a local schedule's description (`oats schedule update <id> --description=<text>`),
1256
+ * under the same locks as an update. A label is not what a run is tracked by, so it may change
1257
+ * while the job runs or has an unresolved attempt; nothing else of the stored job is touched or
1258
+ * re-validated. `""` or null removes it. */
1259
+ export function updateScheduleDescription(ws, qid, description, io) {
1260
+ const id = localScheduleId(qid, "update");
1261
+ const clear = description === "" || description === null || description === undefined;
1262
+ if (!clear) validateDescription(description, "E_SCHEDULE_INVALID");
1263
+ return withHostLock(() => withScopeLock(ws, () => {
1264
+ const defs = readDefinitions(ws);
1265
+ if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
1266
+ const { description: _d, ...rest } = defs.jobs[id]; void _d;
1267
+ defs.jobs[id] = { ...rest, ...(clear ? {} : { description }), updatedAt: new Date().toISOString() };
1268
+ writeDefinitions(ws, defs);
1269
+ return describe(ws, localId(id), io);
1270
+ }), { retryMs: 3000 });
1271
+ }
1259
1272
  export function setEnabled(ws, qid, enabled, io) {
1260
1273
  const id = localScheduleId(qid, "change");
1261
1274
  return withScopeLock(ws, () => {
package/lib/servers.mjs CHANGED
@@ -783,8 +783,11 @@ function listSnapshotServers() {
783
783
  * action authority. */
784
784
  /** The facts a remote row relays from the host's `status --json` row, as the
785
785
  * host reported them; a fact the host does not supply is null, never
786
- * derived here. */
787
- export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
786
+ * derived here. `work`, `repo`, `branch` and `modelFrom` are the host's own
787
+ * record (`repo` is a path on the host, never read here); `soul` and `modules`
788
+ * carry the host's drift observation against its own members and lock, not
789
+ * recomputed here (#675). */
790
+ export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin", "work", "repo", "branch", "modelFrom", "soul", "modules"];
788
791
  const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
789
792
  /** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
790
793
  * key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
@@ -1100,6 +1103,11 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
1100
1103
  throw e;
1101
1104
  }
1102
1105
  if (setsHostCaps && !remote.features.includes("schedule-host-caps")) throw capsUnsupported();
1106
+ // --description on add/update (0.43.0): an older kernel would ignore it on add, or refuse a
1107
+ // description-only update as a missing --file; refused here before anything is forwarded.
1108
+ if (["add", "update"].includes(oatsArgs[0]) && oatsArgs.some((a) => a === "--description" || a.startsWith("--description=")) && !remote.features.includes("automation-descriptions")) {
1109
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} (${serverId}) does not advertise automation-descriptions, so it cannot take --description; upgrade oats on that destination, or put description in the spec`);
1110
+ }
1103
1111
  if (["add", "update"].includes(oatsArgs[0])) {
1104
1112
  const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
1105
1113
  if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");