@awebai/oats 0.24.9 → 0.24.11
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
CHANGED
|
@@ -4323,6 +4323,8 @@ function spawnCmd() {
|
|
|
4323
4323
|
...(flag("base") !== undefined && flag("base") !== true ? { baseRef: flag("base") } : {}),
|
|
4324
4324
|
// A confirmed preview binds this apply (K6b): drift → E_DECISION_STALE, nothing created.
|
|
4325
4325
|
...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
|
|
4326
|
+
// K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
|
|
4327
|
+
...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
|
|
4326
4328
|
});
|
|
4327
4329
|
if (args.includes("--preview")) { if (JSON_MODE) { jsonOk(r); return; } console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created`); return; }
|
|
4328
4330
|
} catch (e) {
|
|
@@ -4342,14 +4344,24 @@ function spawnCmd() {
|
|
|
4342
4344
|
if (["E_BRANCH_EXISTS", "E_BASE_UNKNOWN"].includes(e?.code)) { bail(e.code, e.message); throw e; }
|
|
4343
4345
|
// K6b: the confirmed decision drifted — the fresh decision travels with the refusal so a GUI re-previews.
|
|
4344
4346
|
if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
|
|
4347
|
+
if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
|
|
4348
|
+
if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
|
|
4349
|
+
if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
|
|
4345
4350
|
bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
4346
4351
|
}
|
|
4347
4352
|
// The instance exists from here on: a failed wake save is reported beside
|
|
4348
4353
|
// the full receipt, never hidden, and never causes a second spawn.
|
|
4349
4354
|
let wakeSchedule, wakeScheduleError;
|
|
4350
|
-
if (wake) {
|
|
4355
|
+
if (wake && r.replayed !== true) { // a replayed receipt re-saves nothing
|
|
4351
4356
|
try { wakeSchedule = saveWakeForHome(scheduleScopeOf(workspaceOf(root)), { instance: r.instance, home: r.home, wake }); }
|
|
4352
4357
|
catch (e) { wakeScheduleError = { code: e.code || "E_SCHEDULE_FAILED", message: e.message }; r.warnings = [...(r.warnings || []), `wake schedule NOT saved: ${e.message}`]; }
|
|
4358
|
+
// K6e: record the wake outcome in the home so a same-key replay can report
|
|
4359
|
+
// it instead of leaving "saved or not?" to inference.
|
|
4360
|
+
if (r.spawnIdempotencyKey) {
|
|
4361
|
+
try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: true, saved: !wakeScheduleError, error: wakeScheduleError ?? null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; } catch { /* the receipt still says it */ }
|
|
4362
|
+
}
|
|
4363
|
+
} else if (r.spawnIdempotencyKey && r.replayed !== true) {
|
|
4364
|
+
try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: false, saved: null, error: null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; } catch { /* nothing to record */ }
|
|
4353
4365
|
}
|
|
4354
4366
|
if (JSON_MODE) {
|
|
4355
4367
|
// Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
|
|
@@ -4363,6 +4375,9 @@ function spawnCmd() {
|
|
|
4363
4375
|
spawnOrigin: r.spawnOrigin, attach: r.attach,
|
|
4364
4376
|
...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
|
|
4365
4377
|
...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
|
|
4378
|
+
// K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
|
|
4379
|
+
...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
|
|
4380
|
+
...(r.wake !== undefined ? { wake: r.wake } : {}), // {requested, saved|null, error}: saved:null = outcome not recorded
|
|
4366
4381
|
launchConfig: r.launch?.launchConfig ?? null, launch: r.launch || null, // already redacted by the kernel
|
|
4367
4382
|
});
|
|
4368
4383
|
return;
|
|
@@ -5018,7 +5033,7 @@ function versionCmd() {
|
|
|
5018
5033
|
// on it (an older CLI without the surface must fail closed with a
|
|
5019
5034
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
5020
5035
|
// must see before relying on them (retire-home: retire --home).
|
|
5021
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2"], instanceGitApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
5036
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
5022
5037
|
return;
|
|
5023
5038
|
}
|
|
5024
5039
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
|
|
4
4
|
|
|
5
|
-
**Last update:** 2026-09-22
|
|
5
|
+
**Last update:** 2026-09-22 21:30Z · **v0.24.10 PUBLISHED** (tag `d47dda94`; tarball shasum `030bcc3e`; bump PR86 → main `27f9f334`; artifact probe: preview API 2 → keyed apply → explicit-branch same-key replay → E_IDEMPOTENCY_CONFLICT → concurrent E_PLACEMENT_TAKEN/winner → keyed home retires clean) · **6b COMPLETE**: READ (PR78) + APPLY companion (PR85) · kernel K6b–K6f (PR76/80/81/82/83/84) · engineer → **7b (K7 instance events) proposal** → 8 · open contracts: K11, attach-knowledge, auto-PR, branch enumeration
|
|
6
6
|
|
|
7
7
|
Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
|
|
8
8
|
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -269,6 +269,58 @@ the pre-fix marker and is never accepted for dispatch.
|
|
|
269
269
|
own process group and is group-killed on timeout; `preflight {status:
|
|
270
270
|
complete|timeout, budgetMs, elapsedMs}` says which. A hanging runtime cannot
|
|
271
271
|
hang a preview.
|
|
272
|
+
- **Confirmed apply contract** (0.24.10+, feature `spawn-apply-2`,
|
|
273
|
+
`spawnApplyApi: 1`) — what a GUI may promise at "Confirm spawn":
|
|
274
|
+
- `decision` gains **`effective {repo, work, runtime, model, launchConfig,
|
|
275
|
+
yolo, backend, childSpawns, relation{kind, anchor{instance, agentsRoot}}}`**
|
|
276
|
+
and `revision` hashes placement + effective. An inherited default that would
|
|
277
|
+
change what launches (the soul's model edited between preview and apply,
|
|
278
|
+
say) → `E_DECISION_STALE`. A GUI does not re-resolve anything itself.
|
|
279
|
+
- **No effect before the fence**: backend presence and `ensureHerdr` run only
|
|
280
|
+
AFTER a successful `--expect-decision` binding and after the placement
|
|
281
|
+
reservation. A stale apply with `--backend herdr` starts nothing. (The
|
|
282
|
+
parent-policy refusal still appends `child-spawn-refused` to the PARENT's
|
|
283
|
+
log on a non-preview apply — that is an audit of a real refusal, not an
|
|
284
|
+
effect on the target.)
|
|
285
|
+
- **Exclusive placement**: the home is reserved with a non-recursive `mkdir`
|
|
286
|
+
immediately after the decision check; a concurrent spawn that lost refuses
|
|
287
|
+
**`E_PLACEMENT_TAKEN`** having touched nothing. Two concurrent applies of
|
|
288
|
+
one decision yield exactly one home. There is no wider lock; this
|
|
289
|
+
reservation is the guarantee.
|
|
290
|
+
- Gate confirmation AND the exec owner on `spawn-preview-2` +
|
|
291
|
+
`spawn-apply-2` + `spawn-idempotency`; a legacy local request on such a CLI
|
|
292
|
+
is refused by the Desktop (`E_PLAN_REQUIRED`), not routed around the fence.
|
|
293
|
+
- **Replay custody** (0.24.10+, feature **`spawn-idempotency-2`** — gate on
|
|
294
|
+
this, not on `spawn-idempotency`, whose replay could be blocked by
|
|
295
|
+
`E_BRANCH_EXISTS`): key recovery runs **first**, right after the name is
|
|
296
|
+
decided and before any placement/branch/base/preflight/backend work — so a
|
|
297
|
+
retry of a spawn that created its explicit branch still reaches its receipt.
|
|
298
|
+
The key-bearing home records `spawnCompleted:false` at its first write and
|
|
299
|
+
`true` only after launch + lineage + final events; a same-key retry of an
|
|
300
|
+
unfinished spawn refuses **`E_SPAWN_INCOMPLETE`** (`details.{instance, home,
|
|
301
|
+
launched}`; remedy is the session surface, never another spawn). The key
|
|
302
|
+
lives in the home by design: durable across the GUI's restart, gone with a
|
|
303
|
+
retired home — after a retire, "check result" is a roster question. The wake
|
|
304
|
+
outcome is recorded (`wake {requested, saved, error}`) and returned on
|
|
305
|
+
replay; `saved:null` means *not recorded* (crash in the interval) — render
|
|
306
|
+
"Agent created; wake outcome unavailable — check Schedules", never
|
|
307
|
+
saved/not-saved without the record.
|
|
308
|
+
- **Retention stays clean**: the completion marker and the wake record are
|
|
309
|
+
kernel writes to `instance.json` made after the spawn's retirement baseline;
|
|
310
|
+
the kernel re-stamps the baseline's home fingerprint after each, so a fresh
|
|
311
|
+
keyed home retires with **no** `changed instance-home bytes` — only the
|
|
312
|
+
agent's own changes ever read as work to recover.
|
|
313
|
+
- **Idempotent apply** (0.24.10+, feature `spawn-idempotency`): `spawn …
|
|
314
|
+
--expect-decision <rev> --idempotency-key <key>` records the key and the
|
|
315
|
+
decision in the new home's `instance.json`; a **retry with the same key**
|
|
316
|
+
replays the recorded receipt (`replayed: true`, same instance/home, no second
|
|
317
|
+
spawn, no wake re-saved) — found by key across the soul's instances, never by
|
|
318
|
+
name (the planned name may have been auto-suffixed past it, which is exactly
|
|
319
|
+
the retry case). The same key with a *different* decision refuses
|
|
320
|
+
**`E_IDEMPOTENCY_CONFLICT`** (`details.instance/home` of the prior spawn); a
|
|
321
|
+
different key with a fresh decision is a genuinely new confirmation. Mint the
|
|
322
|
+
key server-side on the first confirmation and keep it for that intent's
|
|
323
|
+
retries (as 2c does); a lost response is a replay, never a guess by name.
|
|
272
324
|
- Still absent (named follow-ups, not parity-done): attach-knowledge node refs
|
|
273
325
|
(provider contract), auto-PR (P1/ADE write approval), branch enumeration
|
|
274
326
|
(producer seam).
|
|
@@ -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,30 @@
|
|
|
1
|
+
# OATS v0.24.11 — retirement recovery: kernel-owned fields only, nothing re-blessed after launch
|
|
2
|
+
|
|
3
|
+
Kernel/Pi/Desktop **0.24.11**. Tag `v0.24.11` → the commit carrying these
|
|
4
|
+
notes; the version-bump commit lands after the tag. **Upgrade from 0.24.10 —
|
|
5
|
+
it can lose an agent's early home bytes at retire.**
|
|
6
|
+
|
|
7
|
+
## Fix
|
|
8
|
+
|
|
9
|
+
0.24.10 (PR84) made a fresh keyed spawn retire clean by re-hashing the **whole
|
|
10
|
+
home** after the kernel's own post-spawn writes (completion marker, wake
|
|
11
|
+
record). That re-stamp ran after the tmux launch, so anything the agent wrote
|
|
12
|
+
in the launch→completion interval — `STATE.md`, notes — was blessed into the
|
|
13
|
+
retirement baseline and **not recovered** at retire. Found by the Desktop
|
|
14
|
+
engineer's exact-source inert analysis of the merged fix.
|
|
15
|
+
|
|
16
|
+
Now the re-stamp is gone. The baseline fingerprint hashes the root
|
|
17
|
+
`instance.json` with exactly the kernel-owned post-spawn fields
|
|
18
|
+
(`spawnCompleted`, `wake`) removed. Kernel writes never read as the agent's
|
|
19
|
+
changes; **nothing else in the home is ever re-blessed**; there is no
|
|
20
|
+
observation of the home after launch. Regression: authored bytes written at
|
|
21
|
+
launch are recovered under `changed instance-home bytes` with the bytes
|
|
22
|
+
intact; a plain keyed spawn still retires with `workRecovery: null`.
|
|
23
|
+
|
|
24
|
+
Rule recorded for the framework: the retirement baseline may account only for
|
|
25
|
+
bytes the kernel can *prove* it wrote — never for what it *observes* after the
|
|
26
|
+
agent may have started.
|
|
27
|
+
|
|
28
|
+
## Upgrade
|
|
29
|
+
|
|
30
|
+
`npm i -g @awebai/oats@0.24.11`, then `oats doctor`.
|
package/lib/core.mjs
CHANGED
|
@@ -5925,6 +5925,25 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5925
5925
|
if (!instance.startsWith(agent.name)) instance = `${agent.name}-${slug(instance)}`;
|
|
5926
5926
|
instance = slug(instance);
|
|
5927
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
|
+
|
|
5928
5947
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
5929
5948
|
// anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
|
|
5930
5949
|
// (CLI --parent) is sugar for relation=child. Parsed and resolved BEFORE any
|
|
@@ -6254,10 +6273,10 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6254
6273
|
// spawn hooks run, which happens after the home exists.
|
|
6255
6274
|
if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
6256
6275
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
6257
|
-
|
|
6258
|
-
//
|
|
6259
|
-
//
|
|
6260
|
-
|
|
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;
|
|
6261
6280
|
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
6262
6281
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
6263
6282
|
|
|
@@ -6279,9 +6298,17 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6279
6298
|
if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
|
|
6280
6299
|
plannedBase = { ref: baseRef, oid: baseOid };
|
|
6281
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
|
+
};
|
|
6282
6310
|
if (o.preview === true) {
|
|
6283
|
-
const decision =
|
|
6284
|
-
decision.revision = createHash("sha256").update(canonicalJson(decision)).digest("hex").slice(0, 24);
|
|
6311
|
+
const decision = buildDecision();
|
|
6285
6312
|
return {
|
|
6286
6313
|
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6287
6314
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
@@ -6299,11 +6326,22 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6299
6326
|
// oid, recomputed here under the same placement path. Any drift is a typed
|
|
6300
6327
|
// refusal carrying the fresh decision — nothing is created, nothing is
|
|
6301
6328
|
// auto-suffixed or silently re-based.
|
|
6302
|
-
const fresh =
|
|
6303
|
-
fresh.revision = createHash("sha256").update(canonicalJson(fresh)).digest("hex").slice(0, 24);
|
|
6329
|
+
const fresh = buildDecision();
|
|
6304
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 });
|
|
6305
6331
|
}
|
|
6306
|
-
|
|
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;
|
|
6307
6345
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6308
6346
|
// package preflight, both of which shell out — a window in which anything able
|
|
6309
6347
|
// to write in the agent directory can swap `instances/` for a link elsewhere,
|
|
@@ -6723,6 +6761,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6723
6761
|
relativeTo: relation ? relativeTo : undefined,
|
|
6724
6762
|
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
6725
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 } : {}),
|
|
6726
6770
|
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
6727
6771
|
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
6728
6772
|
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
@@ -6851,7 +6895,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6851
6895
|
|
|
6852
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 } });
|
|
6853
6897
|
if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
|
|
6854
|
-
|
|
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"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
6902
|
+
}
|
|
6903
|
+
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 };
|
|
6855
6904
|
} catch (error) {
|
|
6856
6905
|
const note = compensateSpawn();
|
|
6857
6906
|
error.message += note;
|
|
@@ -7226,7 +7275,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
7226
7275
|
const st = lstatSync(path);
|
|
7227
7276
|
hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
|
|
7228
7277
|
if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
|
|
7229
|
-
else if (st.isFile()) { hash.update("file\0"); hash.update(readFileSync(path)); hash.update("\0"); }
|
|
7278
|
+
else if (st.isFile()) { hash.update("file\0"); hash.update(!rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); hash.update("\0"); }
|
|
7230
7279
|
else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
|
|
7231
7280
|
else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
|
|
7232
7281
|
}
|
|
@@ -7285,6 +7334,22 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
7285
7334
|
return roots;
|
|
7286
7335
|
}
|
|
7287
7336
|
|
|
7337
|
+
/** The fields the KERNEL writes into a home's instance.json after the spawn's
|
|
7338
|
+
* retirement baseline was taken (completion marker, wake record). The
|
|
7339
|
+
* baseline fingerprint hashes instance.json with exactly these removed, so a
|
|
7340
|
+
* kernel write does not read as the agent's change — and NOTHING ELSE in the
|
|
7341
|
+
* home is ever re-blessed: an agent's STATE.md written seconds after launch
|
|
7342
|
+
* is still recovered at retire. (Replaces a whole-home re-stamp that could
|
|
7343
|
+
* bless authored bytes written in the launch→completion interval.) */
|
|
7344
|
+
const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
|
|
7345
|
+
function kernelNeutralInstanceJson(bytes) {
|
|
7346
|
+
try {
|
|
7347
|
+
const m = JSON.parse(String(bytes));
|
|
7348
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
|
|
7349
|
+
for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
|
|
7350
|
+
return Buffer.from(JSON.stringify(m));
|
|
7351
|
+
} catch { return bytes; }
|
|
7352
|
+
}
|
|
7288
7353
|
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
7289
7354
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
7290
7355
|
const isWorktree = mode === "worktree";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.11",
|
|
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",
|