@awebai/oats 0.24.8 → 0.24.10
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 +69 -13
- package/capabilities/oats-okf/bin/oats-okf.mjs +3 -3
- package/capabilities/oats-okf/lib/binding-wire.mjs +35 -8
- package/capabilities/oats-okf/lib/config.mjs +1 -0
- package/capabilities/oats-okf/lib/sources.mjs +27 -2
- package/capabilities/oats-okf/lib/worker.mjs +5 -2
- package/capabilities/oats-okf/oats.json +29 -6
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/desktop-cli-api.md +138 -0
- package/docs/release-notes/v0.24.10.md +55 -0
- package/docs/release-notes/v0.24.9.md +67 -0
- package/docs/workspace-adoption.md +4 -1
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +124 -11
- package/lib/process-group.mjs +12 -0
- package/lib/readiness.mjs +119 -35
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# OATS v0.24.10 — confirmed, idempotent spawn (apply contract) and the Desktop Review → Confirm spawn flow
|
|
2
|
+
|
|
3
|
+
Kernel/Pi/Desktop **0.24.10**. Tag `v0.24.10` → the commit carrying these
|
|
4
|
+
notes; the version-bump commit lands after the tag. Consumers gate on
|
|
5
|
+
`oats version --json` `features[]` names and API integers — never on the version.
|
|
6
|
+
|
|
7
|
+
## Kernel — the spawn apply contract (K6c–K6f)
|
|
8
|
+
|
|
9
|
+
A Desktop can now let a person **review** exactly what a spawn will do and then
|
|
10
|
+
**confirm** it, with the kernel guaranteeing that what runs is what was
|
|
11
|
+
reviewed — and that a lost response never spawns twice.
|
|
12
|
+
|
|
13
|
+
- **`spawn-apply-2`** (`spawnApplyApi: 1`): the previewed `decision` binds
|
|
14
|
+
**effective launch facts** (`repo, work, runtime, model, launchConfig, yolo,
|
|
15
|
+
backend, childSpawns, relation/anchor`) as well as placement; an inherited
|
|
16
|
+
default edited between preview and apply → `E_DECISION_STALE`. No backend
|
|
17
|
+
(Herdr) is started before the decision fence. The home is reserved with an
|
|
18
|
+
**exclusive** non-recursive `mkdir` right after the check: two concurrent
|
|
19
|
+
applies of one decision produce exactly one home; the loser refuses
|
|
20
|
+
`E_PLACEMENT_TAKEN` having touched nothing.
|
|
21
|
+
- **`spawn-idempotency-2`**: `spawn --expect-decision <rev> --idempotency-key
|
|
22
|
+
<key>` records both in the new home. A **same-key retry replays** the
|
|
23
|
+
recorded receipt (found by key, never by name — the planned name may have
|
|
24
|
+
been auto-suffixed past it), runs **before** any placement/branch/base check
|
|
25
|
+
(so a spawn that created its explicit branch still replays), and re-saves no
|
|
26
|
+
wake. Same key + different decision → `E_IDEMPOTENCY_CONFLICT`. A home whose
|
|
27
|
+
spawn did not finish (crash between metadata and launch) → `E_SPAWN_INCOMPLETE`,
|
|
28
|
+
never a replayed success. The wake outcome is recorded and returned on replay
|
|
29
|
+
(`wake {requested, saved, error}`; `saved:null` = not recorded).
|
|
30
|
+
- Decision-bound receipts echo the **full** bound decision; the kernel's own
|
|
31
|
+
post-spawn writes (completion marker, wake record) re-stamp the retirement
|
|
32
|
+
baseline so a fresh keyed home retires clean.
|
|
33
|
+
- Every one of these came from the Desktop engineer's pre-wiring reads of the
|
|
34
|
+
merged producer, several proved in an inert VM; each is a new advertised name
|
|
35
|
+
because a strengthened guarantee must be distinguishable from the installed
|
|
36
|
+
CLI that lacks it.
|
|
37
|
+
|
|
38
|
+
## Desktop
|
|
39
|
+
|
|
40
|
+
- **Review spawn → Confirm spawn**: the spawn modal prepares a server-owned,
|
|
41
|
+
immutable intent (soul, root, choices, task, wake, the kernel's decision) and
|
|
42
|
+
applies only by reference; the idempotency key is minted at the first
|
|
43
|
+
confirmation and kept for that intent's retries. A lost response shows
|
|
44
|
+
**unknown** with an explicit *Check result* (a same-key kernel replay), never
|
|
45
|
+
a second spawn or a guessed terminal. Stale/conflict refusals require a fresh
|
|
46
|
+
review and a new confirmation. `E_SPAWN_INCOMPLETE` points at the session
|
|
47
|
+
surface; an unrecorded wake outcome says so ("check Schedules"). A raw local
|
|
48
|
+
spawn request on a fully capable CLI is refused (`E_PLAN_REQUIRED`); older or
|
|
49
|
+
remote CLIs keep ordinary spawn with the new choices unavailable. No
|
|
50
|
+
persistent task journal: after a Desktop restart a submitted intent is
|
|
51
|
+
unavailable/unknown and mints nothing.
|
|
52
|
+
|
|
53
|
+
## Upgrade
|
|
54
|
+
|
|
55
|
+
`npm i -g @awebai/oats@0.24.10`, then `oats doctor`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# OATS v0.24.9 — readiness pins, side-effect-free spawn preview (API 2), process-group safety, Desktop Readiness view
|
|
2
|
+
|
|
3
|
+
Kernel/Pi/Desktop **0.24.9**. Tag `v0.24.9` → the commit carrying these notes;
|
|
4
|
+
the version-bump commit lands after the tag. Consumers gate on `oats version
|
|
5
|
+
--json` `features[]` names and API integers — never on the version.
|
|
6
|
+
|
|
7
|
+
## Kernel
|
|
8
|
+
|
|
9
|
+
- **Spawn preview API 2** (feature `spawn-preview-2`, `spawnPreviewApi: 2`).
|
|
10
|
+
API 1 previews **wrote before they returned** — a refused child spawn appended
|
|
11
|
+
an event to the parent, a Herdr backend could be started, an unknown soul
|
|
12
|
+
could be imported from an importable def — and nothing bound a later spawn to
|
|
13
|
+
the previewed decision. API 2: a preview touches nothing, success or refusal
|
|
14
|
+
(proven by a byte-identical deployment tree); `spawn --agents-root <abs>`
|
|
15
|
+
binds the exact root with no fallback and the preview echoes `subject` as
|
|
16
|
+
given; `decision {instance, home, branch, base, revision}` + `spawn
|
|
17
|
+
--expect-decision <rev>` refuses **`E_DECISION_STALE`** with the fresh
|
|
18
|
+
decision on any drift (no auto-suffix, no silent re-base, nothing created);
|
|
19
|
+
every native probe shares one bounded preflight budget and is process-group
|
|
20
|
+
killed on timeout (`preflight {status, budgetMs, elapsedMs}`). **Gate on API
|
|
21
|
+
2 — API 1 is the pre-fix marker.**
|
|
22
|
+
- **Readiness pins** (`oats readiness`): `configured` is *effective* activation
|
|
23
|
+
(a capability declared for the soul but disabled is a fail that says so);
|
|
24
|
+
data-only capabilities (skills/inject, no commands/hooks/env) report trust
|
|
25
|
+
**not-applicable** — the inspect row carries `health.executableSurface`;
|
|
26
|
+
every item is typed (`capability {id, level, scope}`, `origin {kind,
|
|
27
|
+
target}`) and `summary.byCapability[]` regroups the same items with
|
|
28
|
+
`ownReady` vs `ready` (never ready under a `summary.subjectBlockers[]` item
|
|
29
|
+
such as unknown membership); `subject.selector` echoes the arguments as given,
|
|
30
|
+
byte-exact; an unreadable member document is `unknown`, not not-applicable;
|
|
31
|
+
`readiness --home <captured>` refuses `E_UNSUPPORTED_MODE`; `--agents-root`
|
|
32
|
+
documented. **Signature verification** (feature `readiness-verify`) is
|
|
33
|
+
bounded custody: one budget per read, process-group kill, cleanup on
|
|
34
|
+
SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL=/dev/null`, https/ssh only, a closed
|
|
35
|
+
`signature.failure.code` — never stderr.
|
|
36
|
+
- **Process-group safety (high).** Bounded-custody code killed a child's
|
|
37
|
+
process group on timeout with `process.kill(-child.pid)`; a **failed** spawn
|
|
38
|
+
(binary missing → `ENOENT`) reports `pid: 0`, and `kill(-0)` signals the
|
|
39
|
+
caller's own process group — `oats readiness --verify-signatures` without
|
|
40
|
+
`git` would have SIGKILLed the operator's shell/tmux/Desktop backend. One
|
|
41
|
+
guarded helper now refuses non-positive PIDs at every kill site.
|
|
42
|
+
- **OKF 2.1.3** mirrored and pinned in the official catalog: per-cause `check`
|
|
43
|
+
reasons, a named remedy when a soul has no `okf.json` (was a raw ENOENT from
|
|
44
|
+
the required spawn hook), retired drained sources switch their `okf-<id>` job
|
|
45
|
+
off.
|
|
46
|
+
|
|
47
|
+
## Desktop
|
|
48
|
+
|
|
49
|
+
- **Readiness** (frame 09/04): Workspace header entry and first-run
|
|
50
|
+
invitation; the quartet from `oats readiness` with items, remedies (display
|
|
51
|
+
only), *View policy*, *Skip for now* (presentation only). Verify signatures
|
|
52
|
+
and Enrol are shown unavailable with the exact reason until their contracts
|
|
53
|
+
land. Effective-readiness section per scope on the Capabilities view.
|
|
54
|
+
- **Normalized route classification**: one classifier decides every specialized
|
|
55
|
+
IPC route from the normalized pathname — dot-segment, percent-encoded,
|
|
56
|
+
backslash, tab and CRLF aliases can no longer skip a route's frame/epoch
|
|
57
|
+
guard.
|
|
58
|
+
- Spawn modal: kernel **preview** (API 2) for the instance name, home, worktree,
|
|
59
|
+
branch and resolved base — *Suggest* asks the kernel for its default
|
|
60
|
+
candidate; preview-only choices block legacy submission rather than being
|
|
61
|
+
dropped. Apply companion, attach-knowledge, auto-PR and branch enumeration are
|
|
62
|
+
named follow-ups, not parity-done.
|
|
63
|
+
|
|
64
|
+
## Upgrade
|
|
65
|
+
|
|
66
|
+
`npm i -g @awebai/oats@0.24.9`, then `oats doctor`. Desktops on an older CLI
|
|
67
|
+
show the new controls as unavailable until the CLI advertises them.
|
|
@@ -107,7 +107,10 @@ onboarding and legacy roster/knowledge cutover remain separate.
|
|
|
107
107
|
production store or grants are supplied. An acceptance fixture is parent-owned
|
|
108
108
|
and cannot be counted as production knowledge adoption.
|
|
109
109
|
- Current authored expert editions require knowledge **oats.okf@2.1.2** and
|
|
110
|
-
messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults.
|
|
110
|
+
messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults.
|
|
111
|
+
The official catalog now offers **oats.okf 2.1.3** (per-cause `check` reasons,
|
|
112
|
+
a named remedy for a soul without `okf.json`, retired sources switch their
|
|
113
|
+
job off); editions move to it when their owner re-reviews them. These published revisions
|
|
111
114
|
are **not proof that their combined bindings/runtime profile is ready**. The provider
|
|
112
115
|
owner supplies that evidence and any subsequently reviewed compatible revision.
|
|
113
116
|
Do not replace either requirement with none or erase a read edge to launch.
|
package/docs/workspaces.md
CHANGED
|
@@ -71,7 +71,7 @@ name: domain-expert
|
|
|
71
71
|
requires:
|
|
72
72
|
knowledge:
|
|
73
73
|
capability: oats.okf
|
|
74
|
-
source: git:github.com/awebai/oats-okf@v2.1.
|
|
74
|
+
source: git:github.com/awebai/oats-okf@v2.1.3#oats-package
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
This illustrates software selection, not complete OKF provisioning: the chosen capability also needs its own valid knowledge declaration, bindings and accepted base.
|
package/lib/core.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
27
27
|
* (attached as soul default is for service agents — spawn must supply workDir)
|
|
28
28
|
*/
|
|
29
|
-
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
29
|
+
import { execFileSync, execSync, spawn as spawnProcess, spawnSync } from "node:child_process";
|
|
30
30
|
import {
|
|
31
31
|
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
@@ -41,6 +41,7 @@ import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapt
|
|
|
41
41
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
43
|
import { appendEvent } from "./instance-events.mjs";
|
|
44
|
+
import { killGroup } from "./process-group.mjs";
|
|
44
45
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
45
46
|
|
|
46
47
|
import { oatsError } from "./errors.mjs";
|
|
@@ -129,6 +130,30 @@ function legacyOperationalSkills(soulDir) {
|
|
|
129
130
|
// ---------- shell helpers ----------
|
|
130
131
|
function sh(cmdline) { return execSync(cmdline, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
131
132
|
function shTry(cmdline) { try { return sh(cmdline); } catch { return undefined; } }
|
|
133
|
+
/** A native PROBE (a runtime binary asked about its catalogue or packages) under
|
|
134
|
+
* a preview: bounded by what is left of the shared preflight budget, run in its
|
|
135
|
+
* own process group and group-killed on timeout. Cheap lookups (`command -v`)
|
|
136
|
+
* are not probes and never draw from the budget. Outside a preview: shTry. */
|
|
137
|
+
function probeTry(cmdline) {
|
|
138
|
+
if (!previewPreflightBudget) return shTry(cmdline);
|
|
139
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
140
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; return undefined; }
|
|
141
|
+
const r = spawnSync("/bin/sh", ["-c", cmdline], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false" } });
|
|
142
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; return undefined; }
|
|
143
|
+
return r.status === 0 ? String(r.stdout).trim() : undefined;
|
|
144
|
+
}
|
|
145
|
+
let previewPreflightBudget = null;
|
|
146
|
+
/** execFileSync for a native probe: under a preview budget, the timeout is what
|
|
147
|
+
* is left of it and the child is group-killed; otherwise the caller's timeout. */
|
|
148
|
+
function probeExecFile(file, args, options = {}) {
|
|
149
|
+
if (!previewPreflightBudget) return execFileSync(file, args, options);
|
|
150
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
151
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; throw Object.assign(new Error("preflight budget exhausted"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
152
|
+
const r = spawnSync(file, args, { ...options, timeout: Math.min(left, options.timeout ?? left), killSignal: "SIGKILL", detached: true });
|
|
153
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; throw r.error || Object.assign(new Error("probe killed"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
154
|
+
if (r.status !== 0) throw Object.assign(new Error(`probe exited ${r.status}`), { status: r.status, stdout: r.stdout });
|
|
155
|
+
return r.stdout;
|
|
156
|
+
}
|
|
132
157
|
function shIn(cwd, cmdline, timeout = 45000) {
|
|
133
158
|
return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
|
|
134
159
|
}
|
|
@@ -4327,7 +4352,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4327
4352
|
try {
|
|
4328
4353
|
// The SELECTED executable answers (a wrapper or another binary), with
|
|
4329
4354
|
// pi's own controlled list subcommand only: never a launch argument.
|
|
4330
|
-
const out =
|
|
4355
|
+
const out = probeExecFile(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
|
|
4331
4356
|
const rows = [];
|
|
4332
4357
|
// pi dims the path with chalk; strip any escapes before matching.
|
|
4333
4358
|
const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
|
|
@@ -4418,7 +4443,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4418
4443
|
list: (env = process.env, opts = {}) => {
|
|
4419
4444
|
let out;
|
|
4420
4445
|
try {
|
|
4421
|
-
out =
|
|
4446
|
+
out = probeExecFile(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
|
|
4422
4447
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60000, env });
|
|
4423
4448
|
} catch { return []; }
|
|
4424
4449
|
let rows;
|
|
@@ -5422,7 +5447,7 @@ export function resolveModelPreference(model, runtime = "pi") {
|
|
|
5422
5447
|
const [provider, ...rest] = bare.split("/");
|
|
5423
5448
|
const id = rest.join("/");
|
|
5424
5449
|
if (!id) return pref; // bare pattern (no provider) — let pi resolve it
|
|
5425
|
-
const out =
|
|
5450
|
+
const out = probeTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
|
|
5426
5451
|
const found = out.split("\n").some((line) => {
|
|
5427
5452
|
const cols = line.trim().split(/\s+/);
|
|
5428
5453
|
return cols[0] === provider && cols[1] === id;
|
|
@@ -5865,7 +5890,14 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5865
5890
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
5866
5891
|
// Launch selection: a named configuration (explicit, or the soul's
|
|
5867
5892
|
// launch-config default), or none; the runtime and model follow from it.
|
|
5868
|
-
|
|
5893
|
+
// K6b: a preview's native probes (model catalogue, runtime packages) share
|
|
5894
|
+
// ONE budget from here to the return; each probe is group-killed on timeout.
|
|
5895
|
+
const preflightStarted = Date.now(), preflightBudgetMs = o.preview === true ? (Number(process.env.OATS_PREVIEW_PREFLIGHT_BUDGET_MS) || 20000) : undefined;
|
|
5896
|
+
let preflight = { status: "complete", budgetMs: preflightBudgetMs ?? null };
|
|
5897
|
+
if (o.preview === true) previewPreflightBudget = { deadline: preflightStarted + preflightBudgetMs };
|
|
5898
|
+
let launchSelection;
|
|
5899
|
+
try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } }); }
|
|
5900
|
+
catch (e) { previewPreflightBudget = null; throw e; }
|
|
5869
5901
|
const launchConfig = launchSelection.config;
|
|
5870
5902
|
const runtime = launchSelection.runtime;
|
|
5871
5903
|
const model = launchSelection.model;
|
|
@@ -5893,6 +5925,25 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5893
5925
|
if (!instance.startsWith(agent.name)) instance = `${agent.name}-${slug(instance)}`;
|
|
5894
5926
|
instance = slug(instance);
|
|
5895
5927
|
|
|
5928
|
+
// K6e: key recovery FIRST — before any placement, branch, base, preflight or
|
|
5929
|
+
// backend work — so a retry of a spawn that already created its explicit
|
|
5930
|
+
// branch (or whose base moved) still reaches its own receipt.
|
|
5931
|
+
if (o.idempotencyKey !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(String(o.idempotencyKey))) throw oatsError("E_BAD_ARGS", "--idempotency-key must be 1-128 chars of [A-Za-z0-9._:-]");
|
|
5932
|
+
if (o.expectDecision !== undefined && o.idempotencyKey !== undefined) {
|
|
5933
|
+
const instancesDir = join(agent._dir, "instances");
|
|
5934
|
+
const prior = (existsSync(instancesDir) ? readdirSync(instancesDir) : []).filter((n) => !n.startsWith("."))
|
|
5935
|
+
.map((n) => { try { return JSON.parse(readFileSync(join(instancesDir, n, "instance.json"), "utf8")); } catch { return null; } })
|
|
5936
|
+
.find((m) => m && m.spawnIdempotencyKey === o.idempotencyKey);
|
|
5937
|
+
if (prior) {
|
|
5938
|
+
if (prior.decision?.revision !== o.expectDecision) throw Object.assign(oatsError("E_IDEMPOTENCY_CONFLICT", `idempotency key ${o.idempotencyKey} was used for a different decision (${prior.decision?.revision ?? "unrecorded"}); a key binds one confirmed decision`), { instance: prior.instance, home: prior.home });
|
|
5939
|
+
// Completion custody: the home exists from the first metadata write, but
|
|
5940
|
+
// the launch/lineage/events that make it a finished spawn may not have
|
|
5941
|
+
// happened (crash in the interval). Say which, never replay a half-spawn.
|
|
5942
|
+
if (prior.spawnCompleted !== true) throw Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false });
|
|
5943
|
+
return { ...prior, replayed: true, launch: undefined, command: undefined, wake: prior.wake ?? { requested: null, saved: null, error: null } };
|
|
5944
|
+
}
|
|
5945
|
+
}
|
|
5946
|
+
|
|
5896
5947
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
5897
5948
|
// anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
|
|
5898
5949
|
// (CLI --parent) is sugar for relation=child. Parsed and resolved BEFORE any
|
|
@@ -6100,7 +6151,9 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6100
6151
|
const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
|
|
6101
6152
|
const policy = childPolicyOf(parentMeta);
|
|
6102
6153
|
if (policy.allowed === false) {
|
|
6103
|
-
|
|
6154
|
+
// A preview touches nothing — not even the parent's event log; the
|
|
6155
|
+
// typed refusal IS the preview's answer.
|
|
6156
|
+
if (parentHome && o.preview !== true) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
|
|
6104
6157
|
throw Object.assign(oatsError("E_CHILD_SPAWNS_DISABLED", `${parentInstance} does not allow child spawns (policy origin: ${policy.origin?.kind ?? "recorded"}${policy.origin?.detail ? ` — ${policy.origin.detail}` : ""}); nothing was spawned. Spawn without a parent relation, or respawn the parent with --allow-child-spawns.`),
|
|
6105
6158
|
{ parent: parentInstance, policy });
|
|
6106
6159
|
}
|
|
@@ -6220,8 +6273,11 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6220
6273
|
// spawn hooks run, which happens after the home exists.
|
|
6221
6274
|
if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
6222
6275
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
6223
|
-
|
|
6224
|
-
|
|
6276
|
+
// Backend presence/startup (ensureHerdr) happens AFTER the decision fence
|
|
6277
|
+
// and the exclusive placement reservation below — a stale or losing apply
|
|
6278
|
+
// must not start a daemon. Preview never starts one either.
|
|
6279
|
+
let herdrBase;
|
|
6280
|
+
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
6225
6281
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
6226
6282
|
|
|
6227
6283
|
if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
|
|
@@ -6242,9 +6298,22 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6242
6298
|
if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
|
|
6243
6299
|
plannedBase = { ref: baseRef, oid: baseOid };
|
|
6244
6300
|
}
|
|
6301
|
+
// The decision a confirmation binds: placement AND what would actually
|
|
6302
|
+
// launch (inherited defaults re-resolved at apply must not drift silently).
|
|
6303
|
+
const buildDecision = () => {
|
|
6304
|
+
const d = { instance, home, branch: plannedBranch, base: plannedBase,
|
|
6305
|
+
effective: { repo: repoAbs, work, runtime, model: model || null, launchConfig: launchConfig?.name ?? null, yolo: yolo ?? null, backend, // backend regardless of --no-launch: the decision is what WOULD launch
|
|
6306
|
+
childSpawns: ownChildPolicy.allowed, relation: relation ? { kind: relation, anchor: { instance: relativeTo ?? null, agentsRoot: anchorHome ? dirname(dirname(dirname(anchorHome))) : null } } : null } };
|
|
6307
|
+
d.revision = createHash("sha256").update(canonicalJson(d)).digest("hex").slice(0, 24);
|
|
6308
|
+
return d;
|
|
6309
|
+
};
|
|
6245
6310
|
if (o.preview === true) {
|
|
6311
|
+
const decision = buildDecision();
|
|
6246
6312
|
return {
|
|
6247
|
-
spawnPreviewApi:
|
|
6313
|
+
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6314
|
+
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6315
|
+
decision, preflight,
|
|
6316
|
+
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
6248
6317
|
runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
6249
6318
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
6250
6319
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
@@ -6252,7 +6321,27 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6252
6321
|
skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
|
|
6253
6322
|
};
|
|
6254
6323
|
}
|
|
6255
|
-
|
|
6324
|
+
if (o.expectDecision !== undefined) {
|
|
6325
|
+
// A confirmed preview binds THIS apply: same name, home, branch and base
|
|
6326
|
+
// oid, recomputed here under the same placement path. Any drift is a typed
|
|
6327
|
+
// refusal carrying the fresh decision — nothing is created, nothing is
|
|
6328
|
+
// auto-suffixed or silently re-based.
|
|
6329
|
+
const fresh = buildDecision();
|
|
6330
|
+
if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
|
|
6331
|
+
}
|
|
6332
|
+
// Exclusive placement reservation: the parent may be created, the home
|
|
6333
|
+
// itself never with `recursive` — EEXIST means another spawn (a concurrent
|
|
6334
|
+
// apply of the same decision, or anything else) got here first, and this one
|
|
6335
|
+
// has touched nothing.
|
|
6336
|
+
mkdirSync(dirname(home), { recursive: true });
|
|
6337
|
+
try { mkdirSync(home); }
|
|
6338
|
+
catch (e) {
|
|
6339
|
+
if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
|
|
6340
|
+
throw e;
|
|
6341
|
+
}
|
|
6342
|
+
// Backend startup only now — the decision is bound and the placement is ours.
|
|
6343
|
+
if (launch && !which(backend)) { try { rmdirSync(home); } catch { /* keep whatever is there */ } throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`); }
|
|
6344
|
+
herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
6256
6345
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6257
6346
|
// package preflight, both of which shell out — a window in which anything able
|
|
6258
6347
|
// to write in the agent directory can swap `instances/` for a link elsewhere,
|
|
@@ -6672,6 +6761,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6672
6761
|
relativeTo: relation ? relativeTo : undefined,
|
|
6673
6762
|
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
6674
6763
|
policy: { childSpawns: ownChildPolicy },
|
|
6764
|
+
// K6c: a decision-bound spawn records what bound it, so a retry with the
|
|
6765
|
+
// same key replays this receipt instead of spawning again.
|
|
6766
|
+
// The FULL bound decision (placement + effective), exactly as the fence
|
|
6767
|
+
// compared it — a receipt echoes what it was bound to, not a subset.
|
|
6768
|
+
...(o.expectDecision !== undefined ? { decision: buildDecision() } : {}),
|
|
6769
|
+
...(o.idempotencyKey !== undefined ? { spawnIdempotencyKey: String(o.idempotencyKey), spawnCompleted: false } : {}),
|
|
6675
6770
|
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
6676
6771
|
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
6677
6772
|
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
@@ -6800,7 +6895,13 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6800
6895
|
|
|
6801
6896
|
appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch } });
|
|
6802
6897
|
if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
|
|
6803
|
-
|
|
6898
|
+
if (o.idempotencyKey !== undefined) {
|
|
6899
|
+
// Only now is the spawn a finished receipt a same-key retry may replay.
|
|
6900
|
+
meta.spawnCompleted = true;
|
|
6901
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
6902
|
+
refreshRetirementBaselineHome(home); // a kernel write, not the agent's
|
|
6903
|
+
}
|
|
6904
|
+
return { ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
6804
6905
|
} catch (error) {
|
|
6805
6906
|
const note = compensateSpawn();
|
|
6806
6907
|
error.message += note;
|
|
@@ -7234,6 +7335,18 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
7234
7335
|
return roots;
|
|
7235
7336
|
}
|
|
7236
7337
|
|
|
7338
|
+
/** Re-stamp ONLY the home fingerprint of an existing baseline after a KERNEL
|
|
7339
|
+
* write to the home (completion marker, wake record). Retirement compares the
|
|
7340
|
+
* home against this baseline; kernel-owned metadata written after spawn must
|
|
7341
|
+
* not read as the agent's "changed instance-home bytes". Everything else in
|
|
7342
|
+
* the baseline (work fingerprint, mode, capabilities) is untouched. */
|
|
7343
|
+
export function refreshRetirementBaselineHome(home) {
|
|
7344
|
+
const path = retirementBaselinePath(home);
|
|
7345
|
+
let baseline; try { baseline = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
|
|
7346
|
+
baseline.homeFingerprint = fingerprintTree(home, { excludeRoot: new Set(["work"]) });
|
|
7347
|
+
writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
|
|
7348
|
+
return true;
|
|
7349
|
+
}
|
|
7237
7350
|
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
7238
7351
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
7239
7352
|
const isWorktree = mode === "worktree";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Kill a detached child's whole process group (e.g. git + its remote helper /
|
|
2
|
+
* ssh, or a runtime probe + what it forked). ONLY for a child that was actually
|
|
3
|
+
* spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
|
|
4
|
+
* `process.kill(0)` address the CALLER's own process group — the operator's
|
|
5
|
+
* shell, tmux session or Desktop backend. Returns whether anything was signalled. */
|
|
6
|
+
export function killGroup(child, signal = "SIGKILL") {
|
|
7
|
+
const pid = child?.pid;
|
|
8
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
9
|
+
try { process.kill(-pid, signal); } catch { /* already gone */ }
|
|
10
|
+
try { process.kill(pid, signal); } catch { /* already gone */ }
|
|
11
|
+
return true;
|
|
12
|
+
}
|