@awebai/oats 0.26.0 → 0.27.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.
- package/bin/oats.mjs +82 -51
- package/docs/capabilities.md +11 -11
- package/docs/capability-manifest.schema.json +16 -5
- package/docs/configuration.md +2 -2
- package/docs/conventions.md +2 -2
- package/docs/desktop-cli-api.md +70 -13
- package/docs/desktop-instance-start.md +2 -2
- package/docs/first-team.md +1 -1
- package/docs/implementation.md +3 -3
- package/docs/oats-local.schema.json +5 -4
- package/docs/packages.md +1 -1
- package/docs/release-notes/v0.27.0.md +100 -0
- package/docs/schedules.md +6 -6
- package/docs/servers.md +4 -4
- package/docs/souls-and-instances.md +6 -6
- package/injects/instance-boundary.md +1 -1
- package/lib/core.mjs +255 -207
- package/lib/deprecation.mjs +24 -0
- package/lib/instance-inspect.mjs +4 -3
- package/lib/process-group.mjs +1 -1
- package/lib/remote.mjs +1 -1
- package/lib/schedule.mjs +39 -20
- package/lib/servers.mjs +57 -25
- package/lib/workspace.mjs +7 -0
- package/package.json +1 -1
- package/packages/record/lib/session-roots.mjs +8 -6
- package/skills/soul-craft/SKILL.md +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** lib/deprecation.mjs — the one deprecated-name warning a command answers with.
|
|
2
|
+
*
|
|
3
|
+
* runtime → harness (0.27.0, lead decision call 6): persisted inputs and CLI flags
|
|
4
|
+
* written before 0.27.0 keep working — read either, write new — and every read of
|
|
5
|
+
* the old spelling is noted here. A command answers with ONE warning naming what
|
|
6
|
+
* it read (the JSON envelope's `warnings[]`, or stderr in text mode), never more.
|
|
7
|
+
*/
|
|
8
|
+
const sources = new Set();
|
|
9
|
+
|
|
10
|
+
/** Note one read of `runtime` where `harness` is meant; `where` names the key's home. */
|
|
11
|
+
export function noteRuntimeName(where) { sources.add(where); }
|
|
12
|
+
|
|
13
|
+
/** The command's one `deprecated-runtime-name` warning, or null when nothing old was read. */
|
|
14
|
+
export function runtimeNameWarning() {
|
|
15
|
+
if (!sources.size) return null;
|
|
16
|
+
const list = [...sources];
|
|
17
|
+
return {
|
|
18
|
+
code: "deprecated-runtime-name", key: "runtime", replacement: "harness", sources: list,
|
|
19
|
+
message: `\`runtime\` was renamed to \`harness\` in 0.27.0; the old name is still read here (${list.join("; ")}) and a later release drops it`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** For tests: forget what was noted. */
|
|
24
|
+
export function resetRuntimeNames() { sources.clear(); }
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
|
|
|
14
14
|
import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
15
15
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, withConfigFile } from "./core.mjs";
|
|
17
|
+
import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
|
|
18
18
|
import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
|
|
@@ -75,6 +75,7 @@ function readTextCapped(file, cap = 200_000) {
|
|
|
75
75
|
* messaging provider (a non-messaging operation) gets the record at no remote cost. */
|
|
76
76
|
export async function homeTarget(home, meta, { remoteOptions, discover = true, live: readLive = true } = {}) {
|
|
77
77
|
const realHome = real(home);
|
|
78
|
+
meta = upgradeHomeMeta(meta, realHome);
|
|
78
79
|
// Only the home's own module copies: without <home>/.oats/modules the kernel's
|
|
79
80
|
// manifest lookup would fall through to a classic config chain — never here.
|
|
80
81
|
let manifests = {}; const loadProblems = [];
|
|
@@ -190,7 +191,7 @@ function capabilityRows(t) {
|
|
|
190
191
|
function soulRow(t) {
|
|
191
192
|
const s = t.soul, def = obj(s.definition) ? s.definition : {};
|
|
192
193
|
return { soulsApi: SOULS_API, name: s.name, repoKey: s.repoKey, commit: s.commit, team: s.team, kind: s.external === true ? "external" : s.external === false ? "member" : null, path: s.path,
|
|
193
|
-
description: def.description ?? null, work: def.work ?? null,
|
|
194
|
+
description: def.description ?? null, work: def.work ?? null, harness: def.harness ?? null, model: def.model ?? null,
|
|
194
195
|
declarations: declarationsOf(s.definition), declarationProblems: s.problems,
|
|
195
196
|
instructions: s.soulDir ? readTextCapped(join(s.soulDir, "AGENTS.md")) : null };
|
|
196
197
|
}
|
|
@@ -210,7 +211,7 @@ export function inspectDocument(t, { kernel }) {
|
|
|
210
211
|
// live for a home (`teamsSource: "recorded"` when its workspace could not be read now).
|
|
211
212
|
teams: t.teams ?? null, teamsSource: t.teamsSource ?? null,
|
|
212
213
|
knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
|
|
213
|
-
instance: m ? { home: t.home, instance: m.instance, agent: m.agent,
|
|
214
|
+
instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null, launched: !!m.launched, createdAt: m.createdAt || null,
|
|
214
215
|
resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
|
|
215
216
|
...(m ? { identity: servedIdentityOf(m) } : {}),
|
|
216
217
|
problems: [...t.soul.problems, ...(t.discoveryError ? [t.discoveryError] : []),
|
package/lib/process-group.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Kill a detached child's whole process group (e.g. git + its remote helper /
|
|
2
|
-
* ssh, or a
|
|
2
|
+
* ssh, or a harness probe + what it forked). ONLY for a child that was actually
|
|
3
3
|
* spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
|
|
4
4
|
* `process.kill(0)` address the CALLER's own process group — the operator's
|
|
5
5
|
* shell, tmux session or Desktop backend. Returns whether anything was signalled. */
|
package/lib/remote.mjs
CHANGED
|
@@ -581,7 +581,7 @@ function posixNormalize(p) {
|
|
|
581
581
|
}
|
|
582
582
|
/**
|
|
583
583
|
* The ONE symlink an OATS tree may carry (decision 13): a `CLAUDE.md` whose target
|
|
584
|
-
* is `AGENTS.md` beside it — the Claude-
|
|
584
|
+
* is `AGENTS.md` beside it — the Claude-harness alias of the canonical
|
|
585
585
|
* instructions. Souls carry it at their root; a capability's agents/<name>/ dirs
|
|
586
586
|
* carry it too. Anything else stays refused. The rule is on the PATH (any depth,
|
|
587
587
|
* basename CLAUDE.md); fetchRemoteTree still enforces the target constraint
|
package/lib/schedule.mjs
CHANGED
|
@@ -28,12 +28,13 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
import { Cron } from "croner";
|
|
30
30
|
import { loadLocal } from "./workspace.mjs";
|
|
31
|
+
import { noteRuntimeName } from "./deprecation.mjs";
|
|
31
32
|
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
32
33
|
|
|
33
34
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
34
35
|
export const SCHEDULE_API = 2;
|
|
35
36
|
const ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
36
|
-
const
|
|
37
|
+
const HARNESSES = new Set(["pi", "claude", "codex"]);
|
|
37
38
|
const BACKENDS = new Set(["tmux", "herdr"]);
|
|
38
39
|
const MESSAGE_MAX = 256 * 1024;
|
|
39
40
|
const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
|
|
@@ -58,6 +59,10 @@ export function scheduleExecutionStatus(def, intent, lock) {
|
|
|
58
59
|
/** The definition keys of a captured (versioned) schedule. That path was removed in 0.26. */
|
|
59
60
|
export const CAPTURED_SCHEDULE_KEYS = Object.freeze(["definitionVersion", "recurrencePolicy", "execution", "preparation"]);
|
|
60
61
|
const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/portable path was removed in 0.26); remove this job, or re-add it without definitionVersion, recurrencePolicy, execution and preparation";
|
|
62
|
+
/** runtime → harness (0.27.0, lead call 6: read either, write new): a definition's
|
|
63
|
+
* `runtime` is its `harness`. Stored jobs are read with the new name (readDefinitions)
|
|
64
|
+
* and the next write stores it; a job naming both, differently, is invalid on its own. */
|
|
65
|
+
export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
|
|
61
66
|
export function shapeError(def) {
|
|
62
67
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
63
68
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
@@ -69,6 +74,7 @@ export function shapeError(def) {
|
|
|
69
74
|
// A stored captured (versioned) definition is never evaluated or run (removed in 0.26): it reads
|
|
70
75
|
// as invalid, migration required, on that job only.
|
|
71
76
|
if (CAPTURED_SCHEDULE_KEYS.some((key) => def[key] !== undefined)) return CAPTURED_SCHEDULE_REMOVED;
|
|
77
|
+
if (def.runtime !== undefined && def.harness !== undefined && def.runtime !== def.harness) return HARNESS_CONFLICT;
|
|
72
78
|
return null;
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -133,6 +139,14 @@ function writeJson(path, value) {
|
|
|
133
139
|
}
|
|
134
140
|
export function readDefinitions(ws) {
|
|
135
141
|
const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
|
|
142
|
+
if (doc.jobs && typeof doc.jobs === "object" && !Array.isArray(doc.jobs)) {
|
|
143
|
+
for (const [id, def] of Object.entries(doc.jobs)) {
|
|
144
|
+
if (!def || typeof def !== "object" || Array.isArray(def) || def.runtime === undefined) continue;
|
|
145
|
+
noteRuntimeName(`schedule ${id} in ${definitionsPath(ws)} (its next save records harness)`);
|
|
146
|
+
if (def.harness === undefined) { const { runtime, ...rest } = def; doc.jobs[id] = { ...rest, harness: runtime }; }
|
|
147
|
+
else if (def.harness === def.runtime) { const { runtime, ...rest } = def; void runtime; doc.jobs[id] = rest; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
136
150
|
if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
|
|
137
151
|
return doc;
|
|
138
152
|
}
|
|
@@ -297,7 +311,12 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
297
311
|
if (derived.length > MAX_INSTANCE_NAME) throw scheduleError("E_SCHEDULE_INVALID", `${out.purpose !== undefined ? "purpose" : "id"}: runs would be named ${out.agent}-${stem}-<YYYYMMDDHHMM> (${derived.length} characters); instance names are at most ${MAX_INSTANCE_NAME} characters — shorten the ${out.purpose !== undefined ? "purpose" : "schedule id (or set a shorter purpose)"}`, { field: out.purpose !== undefined ? "purpose" : "id" }); }
|
|
298
312
|
if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
|
|
299
313
|
out.task = def.task;
|
|
300
|
-
if (def.runtime !== undefined) {
|
|
314
|
+
if (def.runtime !== undefined) {
|
|
315
|
+
if (def.harness !== undefined && def.harness !== def.runtime) throw scheduleError("E_SCHEDULE_INVALID", HARNESS_CONFLICT, { field: "runtime" });
|
|
316
|
+
noteRuntimeName(`schedule ${id}: runtime (written as harness)`);
|
|
317
|
+
def = { ...def, harness: def.runtime };
|
|
318
|
+
}
|
|
319
|
+
if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
|
|
301
320
|
if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
|
|
302
321
|
if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
|
|
303
322
|
if (def.wake !== undefined) {
|
|
@@ -352,13 +371,13 @@ export function nextRunAfter(def, from) { const n = cronOf(def).nextRun(from); r
|
|
|
352
371
|
/** Due exactly at this minute: the first run after (minute - 1 s) is this minute. */
|
|
353
372
|
export function dueAt(def, minute) { const n = cronOf(def).nextRun(new Date(minute.getTime() - 1000)); return !!n && n.getTime() === minute.getTime(); }
|
|
354
373
|
export function purposeSuffix(minute) { return minute.toISOString().slice(0, 16).replace(/[-T:]/g, ""); }
|
|
355
|
-
/** Ordering key for admission: when this job last launched a
|
|
374
|
+
/** Ordering key for admission: when this job last launched a harness. */
|
|
356
375
|
export function launchOrderKey(js) { return String(js?.lastLaunchedAt || ""); }
|
|
357
376
|
|
|
358
377
|
// ---------------------------------------------------------- observation
|
|
359
378
|
|
|
360
379
|
/** What a run's instance looks like now. Home gone = ended; a home that is
|
|
361
|
-
* retiring is still there (its
|
|
380
|
+
* retiring is still there (its harness may be alive pending teardown) and
|
|
362
381
|
* keeps its slot; present but no running harness = stopped (needs
|
|
363
382
|
* attention, never removed); running = active; not inspectable = unknown. */
|
|
364
383
|
export function observeHome(home, io) {
|
|
@@ -371,7 +390,7 @@ export function observeHome(home, io) {
|
|
|
371
390
|
if (retiring) return { outcome: "active", note: "retiring: home present, teardown pending" };
|
|
372
391
|
// A shell right after `session start` is that launch's startup phase
|
|
373
392
|
// until its wrapper writes the exit marker for the launch id; only then
|
|
374
|
-
// (or with no start receipt at all) is a shell a finished
|
|
393
|
+
// (or with no start receipt at all) is a shell a finished harness.
|
|
375
394
|
if (s.present && s.state === "shell" && startupInProgress(home)) return { outcome: "starting" };
|
|
376
395
|
return { outcome: "stopped" };
|
|
377
396
|
} catch (e) { return { outcome: "unknown", error: e.message }; }
|
|
@@ -417,7 +436,7 @@ function childEnv() {
|
|
|
417
436
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
418
437
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
419
438
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
420
|
-
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
439
|
+
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
421
440
|
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
422
441
|
]) delete env[key];
|
|
423
442
|
return env;
|
|
@@ -443,7 +462,7 @@ export function workspaceSpawnContext(ws) {
|
|
|
443
462
|
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
444
463
|
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
445
464
|
const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
|
|
446
|
-
if (opts.
|
|
465
|
+
if (opts.harness) argv.push("--harness", opts.harness);
|
|
447
466
|
if (opts.model) argv.push("--model", opts.model);
|
|
448
467
|
if (opts.backend) argv.push("--backend", opts.backend);
|
|
449
468
|
if (opts.repo) argv.push("--repo", opts.repo);
|
|
@@ -479,7 +498,7 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
|
479
498
|
function launchSpawn(ws, def, minute, io) {
|
|
480
499
|
const { root, agent } = resolveScheduledAgent(ws, def);
|
|
481
500
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
|
|
482
|
-
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose,
|
|
501
|
+
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
483
502
|
let r;
|
|
484
503
|
if (io?.spawn) r = io.spawn(root, agent, opts);
|
|
485
504
|
else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
|
|
@@ -561,7 +580,7 @@ function validateActiveExecution(js, lock) {
|
|
|
561
580
|
}
|
|
562
581
|
/** One wake. `startIfStopped` false between due minutes (a harness that
|
|
563
582
|
* keeps exiting is not restarted every minute); `canStart` false when the
|
|
564
|
-
* host bound is reached (a new
|
|
583
|
+
* host bound is reached (a new harness takes a slot; a delivery does not). */
|
|
565
584
|
/** A failure message that reports effects the kernel could not undo or
|
|
566
585
|
* confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
|
|
567
586
|
* retained home): the launch's effects are unconfirmed, never a confirmed
|
|
@@ -580,14 +599,14 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
580
599
|
// The slot is persisted BEFORE the start call and kept on ANY start
|
|
581
600
|
// exception: an error code does not prove nothing was allocated (core
|
|
582
601
|
// can refuse while recording, after the session exists). The next
|
|
583
|
-
// observation releases it once the
|
|
602
|
+
// observation releases it once the harness is proven stopped or absent;
|
|
584
603
|
// at worst a refused start occupies a slot for one tick.
|
|
585
604
|
if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
|
|
586
605
|
try {
|
|
587
606
|
const r = (io?.start || startInstanceSession)(def.home);
|
|
588
|
-
return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true,
|
|
607
|
+
return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedHarness: true };
|
|
589
608
|
} catch (e) {
|
|
590
|
-
return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true,
|
|
609
|
+
return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedHarness: "unconfirmed" };
|
|
591
610
|
}
|
|
592
611
|
}
|
|
593
612
|
try {
|
|
@@ -602,9 +621,9 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
602
621
|
|
|
603
622
|
/** Observe the previous run of one job. The tracked identity is what was
|
|
604
623
|
* launched (the lock's or lastRun's home), never the current definition,
|
|
605
|
-
* so an edit cannot make a slot forget its
|
|
606
|
-
* while its started
|
|
607
|
-
* released when that
|
|
624
|
+
* so an edit cannot make a slot forget its harness. A wake slot is held
|
|
625
|
+
* while its started harness is active, starting, unknown or retiring and
|
|
626
|
+
* released when that harness is proven stopped (the start receipt's exit
|
|
608
627
|
* marker) or its home is gone: a persistent home outliving its process
|
|
609
628
|
* does not keep a slot. */
|
|
610
629
|
function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
@@ -624,7 +643,7 @@ function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
|
624
643
|
if (seen.outcome === "ended" || seen.outcome === "stopped") {
|
|
625
644
|
releaseJobLock(ws, id);
|
|
626
645
|
if (seen.outcome === "ended") delete js.pendingWake;
|
|
627
|
-
if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started
|
|
646
|
+
if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started harness ended" : "started harness stopped; slot released" };
|
|
628
647
|
}
|
|
629
648
|
return;
|
|
630
649
|
}
|
|
@@ -654,7 +673,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
654
673
|
// `candidates` (the host tick) means the host already observed every scope.
|
|
655
674
|
if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
|
|
656
675
|
if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
|
|
657
|
-
// Least recently LAUNCHED first: only an actual
|
|
676
|
+
// Least recently LAUNCHED first: only an actual harness launch counts, a
|
|
658
677
|
// skipped or pending job keeps its place at the front of the line.
|
|
659
678
|
const order = (candidates || Object.keys(defs.jobs)).filter((id) => Object.prototype.hasOwnProperty.call(defs.jobs, id) && (!only || id === only)).sort((a, b) => launchOrderKey(st.jobs[a]).localeCompare(launchOrderKey(st.jobs[b])) || a.localeCompare(b));
|
|
660
679
|
for (const id of order) {
|
|
@@ -671,7 +690,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
671
690
|
const held = !!jobLockInfo(ws, id);
|
|
672
691
|
const canStart = held || liveLocks(wsAll) < bound;
|
|
673
692
|
const run = performWake(def, io, { startIfStopped: dueNow, canStart, reserve: () => held || acquireJobLock(ws, id, { scheduledFor: js.pendingWake.scheduledFor, wake: true, home: def.home }) });
|
|
674
|
-
if (run.
|
|
693
|
+
if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
|
|
675
694
|
if (!existsSync(def.home)) releaseJobLock(ws, id);
|
|
676
695
|
js.lastRun = { ...(js.lastRun || {}), ...run, outcome: run.action, completedPending: run.action === "delivered", pendingSince: js.pendingWake.scheduledFor, startedAt: js.lastRun?.startedAt || now.toISOString() };
|
|
677
696
|
if (!run.pending) delete js.pendingWake;
|
|
@@ -700,7 +719,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
700
719
|
// one does, and the slot is held (the job lock) until that home ends.
|
|
701
720
|
const holdsSlot = !!jobLockInfo(ws, id);
|
|
702
721
|
const run = performWake(def, io, { startIfStopped: true, canStart: holdsSlot || liveLocks(wsAll) < bound, reserve: () => holdsSlot || acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), wake: true, home: def.home }) });
|
|
703
|
-
if (run.
|
|
722
|
+
if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
|
|
704
723
|
if (!existsSync(def.home)) releaseJobLock(ws, id);
|
|
705
724
|
if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
|
|
706
725
|
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action, ...(run.instance ? { instance: run.instance } : {}) };
|
|
@@ -856,7 +875,7 @@ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(
|
|
|
856
875
|
* transcript: there is no reader behind this block. */
|
|
857
876
|
function sessionProvenanceOf(r) {
|
|
858
877
|
if (!r || typeof r !== "object") return null;
|
|
859
|
-
const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
|
|
878
|
+
const delivery = r.startedHarness || r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
|
|
860
879
|
return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
|
|
861
880
|
}
|
|
862
881
|
/** Read-time projection of one job's stored history: bounded to
|
package/lib/servers.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join, resolve } from "node:path";
|
|
24
24
|
import { parseStrictJson } from "./canonical-json.mjs";
|
|
25
|
+
import { noteRuntimeName } from "./deprecation.mjs";
|
|
25
26
|
|
|
26
27
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
27
28
|
export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
|
|
@@ -224,53 +225,64 @@ export function checkRemote(target, io = {}) {
|
|
|
224
225
|
}
|
|
225
226
|
// What the remote kernel advertises it can launch. A probe without these
|
|
226
227
|
// fields is a 0.22.1-class kernel: pi and claude only, no session backend
|
|
227
|
-
// choice, no launch options; nothing newer may be requested of it.
|
|
228
|
+
// choice, no launch options; nothing newer may be requested of it. The
|
|
229
|
+
// harness list is named `harnesses` since 0.27.0; an older kernel names the
|
|
230
|
+
// same list `runtimes`, and is read in its own vocabulary (lead call 6).
|
|
228
231
|
const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
|
|
232
|
+
const harnessKey = Array.isArray(probe.harnesses) ? "harnesses" : "runtimes";
|
|
229
233
|
return {
|
|
230
234
|
version: probe.version, schemaVersion: 1, desktopApi: 1,
|
|
231
|
-
|
|
235
|
+
harnesses: list(harnessKey, ["pi", "claude"]),
|
|
232
236
|
sessionBackends: list("sessionBackends", []),
|
|
233
237
|
launchOptions: list("launchOptions", []),
|
|
234
238
|
remote: list("remote", []),
|
|
235
239
|
features: list("features", []),
|
|
236
240
|
operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
|
|
237
241
|
scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
|
|
238
|
-
advertised: Array.isArray(probe
|
|
242
|
+
advertised: Array.isArray(probe[harnessKey]),
|
|
239
243
|
};
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
/** Refuse, before any mutation, a spawn that asks the remote kernel for a
|
|
243
|
-
*
|
|
244
|
-
* effective
|
|
247
|
+
* harness, session backend or launch option it does not advertise. The
|
|
248
|
+
* effective harness is the --harness flag or the soul's default from the
|
|
245
249
|
* remote roster, resolved THERE, since the local roster says nothing about
|
|
246
250
|
* that host's souls. Desktop's local support check cannot prove remote
|
|
247
251
|
* support; this is the proof. */
|
|
252
|
+
/** The client speaks the host's vocabulary (0.27.0, lead call 6): a host that does not
|
|
253
|
+
* advertise the harness feature is asked with `--runtime`, the name it reads. */
|
|
254
|
+
export function hostHarnessArgs(remote, args) {
|
|
255
|
+
if ((remote?.features || []).includes("harness")) return args;
|
|
256
|
+
return args.map((a) => (a === "--harness" ? "--runtime" : a.startsWith("--harness=") ? `--runtime=${a.slice("--harness=".length)}` : a));
|
|
257
|
+
}
|
|
248
258
|
export function checkRemoteSupport(remote, target, oatsArgs, roster) {
|
|
249
259
|
const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
|
|
250
260
|
const agent = oatsArgs.find((a) => !a.startsWith("--"));
|
|
251
261
|
const soul = (roster?.agents || []).find((a) => a.name === agent);
|
|
252
|
-
// The effective
|
|
253
|
-
// roster reports it. A soul the roster does not list with a
|
|
262
|
+
// The effective harness is the flag, else the soul's default as the remote
|
|
263
|
+
// roster reports it. A soul the roster does not list with a harness (a
|
|
254
264
|
// capability-defined agent, or one with no live instance) has no default
|
|
255
265
|
// this side can establish: the remote kernel validates its own default at
|
|
256
266
|
// spawn, and nothing is asserted here about it.
|
|
257
267
|
const namedConfig = oatsArgs.includes("--launch-config");
|
|
258
|
-
|
|
268
|
+
// --runtime is the pre-0.27 name of --harness; a roster row from a host before 0.27 says `runtime`.
|
|
269
|
+
const chosen = flagOf("--harness") || flagOf("--runtime");
|
|
270
|
+
const harness = chosen || (namedConfig ? undefined : soul?.harness ?? soul?.runtime);
|
|
259
271
|
// What the message may claim depends on what was established: an
|
|
260
272
|
// advertising remote said what it supports; a silent one (before 0.22.2)
|
|
261
273
|
// said nothing, and only pi and claude are assumed of it.
|
|
262
274
|
const supports = remote.advertised
|
|
263
|
-
? `it advertises
|
|
264
|
-
: `it does not advertise
|
|
275
|
+
? `it advertises harnesses ${remote.harnesses.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
|
|
276
|
+
: `it does not advertise its harnesses (kernels before 0.22.2 do not), so only pi and claude are assumed of it${remote.sessionBackends.length ? `, with session backends ${remote.sessionBackends.join(", ")}` : " on tmux"}${remote.launchOptions.length ? ` and launch options ${remote.launchOptions.join(", ")}` : " with no launch options"}; upgrade it there to use more`;
|
|
265
277
|
const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
|
|
266
278
|
if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
|
|
267
|
-
if (
|
|
279
|
+
if (harness && !remote.harnesses.includes(harness)) refuse(`harness ${harness}${chosen ? "" : ` (the default of soul ${agent} there)`}`);
|
|
268
280
|
// A wake schedule at spawn is saved by the host: it must advertise schedules.
|
|
269
281
|
if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
|
|
270
282
|
const backend = flagOf("--backend");
|
|
271
283
|
if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
|
|
272
284
|
if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
|
|
273
|
-
return {
|
|
285
|
+
return { harness, backend, yolo: oatsArgs.includes("--yolo") };
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
/** The remote kernel version that carries `oats session` (inspect, input,
|
|
@@ -404,13 +416,13 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
|
404
416
|
const ii = oatsArgs.indexOf("--instance");
|
|
405
417
|
const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
|
|
406
418
|
if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
|
|
407
|
-
// The remote roster: the soul's
|
|
419
|
+
// The remote roster: the soul's harness default for the support check,
|
|
408
420
|
// and the remote agents root for the snapshot (the kernel's spawn result
|
|
409
421
|
// does not carry it, and guessing it from the workspace would be wrong).
|
|
410
422
|
const status = runRemote(target, json(withScope(["status"])), io).envelope;
|
|
411
423
|
if (!status.ok) return { envelope: status, stderr: "" };
|
|
412
424
|
checkRemoteSupport(remote, target, oatsArgs, status.result);
|
|
413
|
-
const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
|
|
425
|
+
const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...hostHarnessArgs(remote, oatsArgs)])), io);
|
|
414
426
|
if (envelope.ok && envelope.result?.instance) {
|
|
415
427
|
// A generated name can still collide with a saved route of another
|
|
416
428
|
// soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
|
|
@@ -576,7 +588,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
576
588
|
g.probe = { ok: true };
|
|
577
589
|
g.agentsRoot = status.result.root;
|
|
578
590
|
for (const a of status.result.agents || []) {
|
|
579
|
-
g.souls.push({ name: a.name,
|
|
591
|
+
g.souls.push({ name: a.name, harness: a.harness ?? a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
|
|
580
592
|
// A failed deferred self-retirement needs an operator: it rides with
|
|
581
593
|
// the group, named by agent and instance, as `oats status` prints it.
|
|
582
594
|
for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
|
|
@@ -584,7 +596,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
584
596
|
const snap = routeOf(i);
|
|
585
597
|
g.instances.push({
|
|
586
598
|
server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
|
|
587
|
-
|
|
599
|
+
harness: i.harness || i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
|
|
588
600
|
...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
|
|
589
601
|
running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
|
|
590
602
|
retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
|
|
@@ -597,7 +609,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
597
609
|
// instance is gone there (retired, or its home removed) and the route is
|
|
598
610
|
// stale; with a failed probe nothing is known. Same keys as remote rows.
|
|
599
611
|
for (const snap of bySnapshot.values()) {
|
|
600
|
-
g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot,
|
|
612
|
+
g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
|
|
601
613
|
}
|
|
602
614
|
delete g._snapshots;
|
|
603
615
|
}
|
|
@@ -667,15 +679,15 @@ export function restartRemote(serverId, choices = {}, io = {}) {
|
|
|
667
679
|
return launchRemoteSession(serverId, "restart", choices, io);
|
|
668
680
|
}
|
|
669
681
|
|
|
670
|
-
function remoteLaunchArgs({ launchConfig,
|
|
682
|
+
function remoteLaunchArgs({ launchConfig, harness, model, yolo } = {}) {
|
|
671
683
|
const args = [];
|
|
672
684
|
if (launchConfig !== undefined) {
|
|
673
685
|
if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
|
|
674
686
|
args.push("--launch-config", launchConfig);
|
|
675
687
|
}
|
|
676
|
-
if (
|
|
677
|
-
if (!["pi", "claude", "codex"].includes(
|
|
678
|
-
args.push("--
|
|
688
|
+
if (harness !== undefined) {
|
|
689
|
+
if (!["pi", "claude", "codex"].includes(harness)) throw serverError("E_BAD_ARGS", "harness must be pi, claude or codex");
|
|
690
|
+
args.push("--harness", harness);
|
|
679
691
|
}
|
|
680
692
|
if (model !== undefined && model !== null && model !== "") {
|
|
681
693
|
if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
|
|
@@ -693,7 +705,7 @@ function requireRemoteFeature(remote, target, feature) {
|
|
|
693
705
|
}
|
|
694
706
|
|
|
695
707
|
function launchRemoteSession(serverId, action, choices, io) {
|
|
696
|
-
const { instance, home, launchConfig,
|
|
708
|
+
const { instance, home, launchConfig, harness, yolo } = choices;
|
|
697
709
|
const choiceArgs = remoteLaunchArgs(choices);
|
|
698
710
|
const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
|
|
699
711
|
const remote = requireSessionRemote(route.target, io);
|
|
@@ -701,8 +713,8 @@ function launchRemoteSession(serverId, action, choices, io) {
|
|
|
701
713
|
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
|
|
702
714
|
}
|
|
703
715
|
if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
|
|
704
|
-
if (launchConfig !== undefined ||
|
|
705
|
-
const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
|
|
716
|
+
if (launchConfig !== undefined || harness !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
|
|
717
|
+
const args = ["session", action, "--home", route.home, ...hostHarnessArgs(remote, choiceArgs), "--json"];
|
|
706
718
|
const { envelope, stderr } = runRemote(route.target, args, io);
|
|
707
719
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
|
|
708
720
|
}
|
|
@@ -711,6 +723,15 @@ function launchRemoteSession(serverId, action, choices, io) {
|
|
|
711
723
|
* its saved route. Definitions travel on stdin, never as remote file paths or
|
|
712
724
|
* shell arguments. The host resolves environment references and validates the
|
|
713
725
|
* definition; this client never substitutes its own environment. */
|
|
726
|
+
/** A launch definition in the host's vocabulary: `harness` for a host with the harness feature,
|
|
727
|
+
* `runtime` for one before 0.27.0. Either spelling is read here; both, disagreeing, are refused. */
|
|
728
|
+
function hostLaunchDefinition(remote, definition) {
|
|
729
|
+
const { harness, runtime, ...rest } = definition; // a disagreeing pair was refused before the probe
|
|
730
|
+
if (runtime !== undefined) noteRuntimeName("runtime in the launch configuration definition (sent as the host's harness key)");
|
|
731
|
+
const value = harness ?? runtime;
|
|
732
|
+
if (value === undefined) return rest;
|
|
733
|
+
return (remote?.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value };
|
|
734
|
+
}
|
|
714
735
|
export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
715
736
|
const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
|
|
716
737
|
if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
|
|
@@ -729,6 +750,7 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
|
729
750
|
if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
|
|
730
751
|
if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
|
|
731
752
|
if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
|
|
753
|
+
if (definition.harness !== undefined && definition.runtime !== undefined && definition.harness !== definition.runtime) throw serverError("E_BAD_ARGS", "the definition names harness and runtime (its pre-0.27 name) with different values; nothing was sent");
|
|
732
754
|
input = Buffer.from(JSON.stringify(definition));
|
|
733
755
|
args.push("--file", "-");
|
|
734
756
|
if (keepEnv) args.push("--keep-env");
|
|
@@ -749,7 +771,9 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
|
749
771
|
// stdin belongs only to the set operation, never its preceding version probe.
|
|
750
772
|
const remote = checkRemote(target, { ...io, input: undefined });
|
|
751
773
|
requireRemoteFeature(remote, target, "launch-config");
|
|
752
|
-
|
|
774
|
+
// The host's vocabulary: its harness flag, and the definition's key (read either here).
|
|
775
|
+
if (input !== undefined) input = Buffer.from(JSON.stringify(hostLaunchDefinition(remote, definition)));
|
|
776
|
+
const { envelope, stderr } = runRemote(target, [...hostHarnessArgs(remote, args), "--json"], { ...io, input });
|
|
753
777
|
return { envelope, stderr, target, ...(route ? { route } : {}) };
|
|
754
778
|
}
|
|
755
779
|
|
|
@@ -774,6 +798,14 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
|
|
|
774
798
|
// refuses them, never forwarded to a remote kernel that might still accept one.
|
|
775
799
|
const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => Object.hasOwn(spec, key));
|
|
776
800
|
if (captured.length) throw serverError("E_SCHEDULE_INVALID", `${captured.join(", ")}: captured schedules are refused (the captured/portable path was removed in 0.26); no request was forwarded`);
|
|
801
|
+
// The host's vocabulary for the spawn job's harness (read either here).
|
|
802
|
+
if (Object.hasOwn(spec, "harness") || Object.hasOwn(spec, "runtime")) {
|
|
803
|
+
const { harness, runtime, ...rest } = spec;
|
|
804
|
+
if (harness !== undefined && runtime !== undefined && harness !== runtime) throw serverError("E_SCHEDULE_INVALID", "the schedule names harness and runtime (its pre-0.27 name) with different values; no request was forwarded");
|
|
805
|
+
const value = harness ?? runtime;
|
|
806
|
+
if (runtime !== undefined) noteRuntimeName("runtime in the schedule spec (sent as the host's harness key)");
|
|
807
|
+
oatsArgs = oatsArgs.map((a, i) => (i === indexes[0] + 1 ? JSON.stringify((remote.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value }) : a));
|
|
808
|
+
}
|
|
777
809
|
}
|
|
778
810
|
}
|
|
779
811
|
const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
|
package/lib/workspace.mjs
CHANGED
|
@@ -294,6 +294,13 @@ function fromProblems(remote, capabilities, path, problems, { here }) {
|
|
|
294
294
|
export function validateLocal(value) {
|
|
295
295
|
const problems = validateAgainst(schemaFor("local"), value);
|
|
296
296
|
if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
|
|
297
|
+
// runtime → harness (0.27.0, lead call 6): `runtime`, the pre-0.27 name, is still read; both,
|
|
298
|
+
// disagreeing, are refused.
|
|
299
|
+
if (isObject(value) && isObject(value["launch-configs"])) for (const [name, entry] of Object.entries(value["launch-configs"])) {
|
|
300
|
+
if (isObject(entry) && Object.hasOwn(entry, "runtime") && Object.hasOwn(entry, "harness") && entry.runtime !== entry.harness) {
|
|
301
|
+
problems.push({ path: `/launch-configs/${pointerKey(name)}/runtime`, reason: "harness-conflict", message: `harness ${show(entry.harness)} and runtime ${show(entry.runtime)} disagree: \`runtime\` is the pre-0.27 name of \`harness\` — keep one` });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
297
304
|
return problems;
|
|
298
305
|
}
|
|
299
306
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -16,7 +16,9 @@ export function sourceSessionEnvironment(home, base = process.env) {
|
|
|
16
16
|
}
|
|
17
17
|
const recipe = meta?.launch;
|
|
18
18
|
if (recipe !== undefined) {
|
|
19
|
-
|
|
19
|
+
// Version 1 (a 0.26.0 home) names the harness `runtime`; version 2 `harness`.
|
|
20
|
+
const harness = recipe?.version === 2 ? recipe.harness : recipe?.version === 1 ? recipe.runtime : undefined;
|
|
21
|
+
if (!recipe || !["claude", "pi", "codex"].includes(harness)) {
|
|
20
22
|
throw new Error("cannot resolve source transcript roots: unsupported recorded launch recipe");
|
|
21
23
|
}
|
|
22
24
|
for (const layer of [recipe.hooks?.env, recipe.env]) {
|
|
@@ -32,7 +34,7 @@ export function sourceSessionEnvironment(home, base = process.env) {
|
|
|
32
34
|
}
|
|
33
35
|
// Pi also supports a direct session-directory override. Do not certify
|
|
34
36
|
// default roots when native options direct evidence somewhere else.
|
|
35
|
-
if (
|
|
37
|
+
if (harness === "pi") {
|
|
36
38
|
const args = recipe.args ?? [];
|
|
37
39
|
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new Error("cannot resolve source transcript roots: invalid launch arguments");
|
|
38
40
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -69,13 +71,13 @@ export function nativeDirectory(value, { home = homedir(), cwd = process.cwd(),
|
|
|
69
71
|
/** Native execution-side locations, without existence filtering. Unlike a
|
|
70
72
|
* background observer scan, Claude's native default is exactly ~/.claude,
|
|
71
73
|
* not every .claude* profile found under an observer's HOME. */
|
|
72
|
-
export function nativeLaunchLocations(
|
|
74
|
+
export function nativeLaunchLocations(harness, { cwd, env = process.env, args = [] } = {}) {
|
|
73
75
|
const home = env.HOME || homedir();
|
|
74
76
|
if (!isAbsolute(home)) throw new Error("native launch HOME must be absolute");
|
|
75
77
|
if (!Array.isArray(args) || args.some(a => typeof a !== "string")) throw new Error("invalid native launch arguments");
|
|
76
|
-
if (
|
|
77
|
-
if (
|
|
78
|
-
if (
|
|
78
|
+
if (harness === "claude") return [join(nativeDirectory(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), { home, cwd }), "projects")];
|
|
79
|
+
if (harness === "codex") return [join(nativeDirectory(env.CODEX_HOME || join(home, ".codex"), { home, cwd }), "sessions")];
|
|
80
|
+
if (harness !== "pi") throw new Error("unsupported native record harness");
|
|
79
81
|
let sessionDir = env.PI_CODING_AGENT_SESSION_DIR;
|
|
80
82
|
for (let i = 0; i < args.length; i++) {
|
|
81
83
|
const arg = args[i];
|
|
@@ -81,7 +81,7 @@ plus `knowledge` / `messaging` / `tasks` slots; `none` empties one). The
|
|
|
81
81
|
soul lives in its member repository, which is also what it works on.
|
|
82
82
|
|
|
83
83
|
Runtime, model and permission bypass are not soul fields: they are chosen at
|
|
84
|
-
spawn (`--
|
|
84
|
+
spawn (`--harness`, `--model`, `--yolo`) or by a host's named launch
|
|
85
85
|
configuration, so the same soul runs on any harness a host provides. Check a
|
|
86
86
|
soul with `oats spawn <soul> --preview` before committing it.
|
|
87
87
|
|