@awebai/oats 0.24.9 → 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 CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
32
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
33
- findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
33
+ findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions, refreshRetirementBaselineHome,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
36
36
  } from "../lib/core.mjs";
@@ -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; refreshRetirementBaselineHome(r.home); } 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; refreshRetirementBaselineHome(r.home); } 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 17:30Z · main `1b8349e5` · Slice 5 ✅ (PR71) · security follow-up ✅ (PR73) · K5 pins ✅ (PR74/75) · **K6b ✅ (PR76: `spawnPreviewApi 2` / `spawn-preview-2` — side-effect-free preview, `--agents-root`, `--expect-decision`/`E_DECISION_STALE`, bounded preflight)** · **PR77 ✅ killGroup pid-0 guard (HIGH; engineer-found)** · OKF 2.1.3 ✅ · engineer → **6b READ wiring** (API 2 gate) → 6b apply companion (proposal) → 7b → 8 · open contracts: K11 admission, attach-knowledge (OKF node refs), auto-PR (P1 write approval), branch enumeration · **0.24.9** after 6b read (release-notes file FIRST)
5
+ **Last update:** 2026-09-22 19:10Z · **v0.24.9 PUBLISHED** (tag `04a4f709`; both packages on npm, tarball shasum `6333c01c`; bump PR79 → main `a5cc390e`; artifact probe: readiness/preview API 2/bound apply/E_DECISION_STALE/retire retention/no-git verify all pass, caller alive, soul intact) · **6b READ MERGED** (PR78) · K6b ✅ · PR77 ✅ · engineer → **6b APPLY companion proposal** → 7b → 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
 
@@ -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`.
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
- if (launch && o.preview !== true && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
6258
- // Preview never starts a backend daemon: it reports reachability as observed
6259
- // (binary present?) and leaves the socket alone.
6260
- const herdrBase = launch && backend === "herdr" && o.preview !== true ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
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 = { instance, home, branch: plannedBranch, base: plannedBase };
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 = { instance, home, branch: plannedBranch, base: plannedBase };
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
- mkdirSync(home, { recursive: true });
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,13 @@ ${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
- return { ...meta, 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 };
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 };
6855
6905
  } catch (error) {
6856
6906
  const note = compensateSpawn();
6857
6907
  error.message += note;
@@ -7285,6 +7335,18 @@ function retirementDisposableRoots(work, workMode, capabilities) {
7285
7335
  return roots;
7286
7336
  }
7287
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
+ }
7288
7350
  function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
7289
7351
  if (mode === "directory") assertDirectoryRoots(home);
7290
7352
  const isWorktree = mode === "worktree";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.9",
3
+ "version": "0.24.10",
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",