@awebai/oats 0.24.11 → 0.24.13

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
@@ -3144,8 +3144,9 @@ function instanceCmd() {
3144
3144
  if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
3145
3145
  try {
3146
3146
  const { resolveInstance } = await_import_lifecycle();
3147
- let home = homeOpt;
3148
- if (!home) home = resolveInstance(dirFlag(), root, name).home;
3147
+ // K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
3148
+ // exactly this name under the scope (E_HOME_MISMATCH otherwise).
3149
+ const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
3149
3150
  const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
3150
3151
  if (JSON_MODE) { jsonOk(ev); return; }
3151
3152
  console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
@@ -4544,7 +4545,11 @@ function scheduleCmd() {
4544
4545
  }
4545
4546
  default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
4546
4547
  }
4547
- } catch (e) { cmdFail(e.code || "E_SCHEDULE_FAILED", e.message); }
4548
+ } catch (e) {
4549
+ // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
4550
+ const details = Object.fromEntries(["key", "declared", "source", "field"].filter((k) => e[k] !== undefined).map((k) => [k, e[k]]));
4551
+ if (JSON_MODE) jsonFail(e.code || "E_SCHEDULE_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
4552
+ }
4548
4553
  }
4549
4554
 
4550
4555
  async function sessionCmd() {
@@ -5033,7 +5038,7 @@ function versionCmd() {
5033
5038
  // on it (an older CLI without the surface must fail closed with a
5034
5039
  // reason, not an argument error). `features`: kernel abilities a peer
5035
5040
  // must see before relying on them (retire-home: retire --home).
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"] }));
5041
+ 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", "instance-events-2", "schedule-history", "schedule-read-2", "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: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5037
5042
  return;
5038
5043
  }
5039
5044
  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 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
5
+ **Last update:** 2026-09-22 23:45Z · v0.24.12 published · **slice 8 approved** (no polling; command-running GET `/api/schedules` to be REMOVED; remote history unsupported; captured edit = honest limitation; transcript = provenance only, read-only transcript verb = K12 Decision later) · **K8b PR95 → main `ee60d1e2`** (`schedule-read-2` / `scheduleHistoryApi 3`: runId by time, bounded fd state reads + 1 MiB budget, per-job isolation, scope/id echo, `session` provenance replaces `transcript`) · 12 receipts to engineer · engineer wiring 8 → then rest of frame 10 · open: K11, K12 transcript, 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
 
@@ -156,7 +156,7 @@ returns a bounded unified diff:
156
156
 
157
157
  No forge (PR/checks/reviews) data here: forge connections are an ADE/workstation integration (P1 decision), read by the Desktop server through the forge's own CLI; the kernel only reports the instance's `remote` so the ADE can pick a backend.
158
158
 
159
- ## Instance events (`oats instance events`, `eventsApi: 1`, OATS 0.24.8+) — K7
159
+ ## Instance events (`oats instance events`, `eventsApi: 1` → **2**, OATS 0.24.8+) — K7
160
160
 
161
161
  Typed lifecycle events per instance, **written by the kernel action that made
162
162
  them true**, with the receipt it produced. Nothing is inferred from
@@ -185,7 +185,67 @@ home's removal, so a retired instance's `retired` event is still readable).
185
185
  - Window is bounded (`--limit`, default 200; `truncated` says so). A torn line
186
186
  appears as `kind: "unreadable"` rather than vanishing.
187
187
 
188
- ## Schedule run history (`scheduleApi: 2`, OATS 0.24.8+) — K8
188
+ ### Events API 2 (`eventsApi: 2`, feature `instance-events-2`, OATS 0.24.12+) — K7b
189
+
190
+ Gate a Desktop read on **both** `eventsApi === 2` and `"instance-events-2"` in
191
+ `features[]`. API 1 is not a sufficient fence for a bounded read: its reader
192
+ opened and read a source whole, followed symlinks, kept foreign rows and lost
193
+ torn lines and cleared claims silently. API 2:
194
+
195
+ - **Bounded, descriptor-safe read.** Each source (`home` =
196
+ `<home>/.oats-events.jsonl`, `workspace` = `<ws>/.agents/events/<agent>--<instance>.jsonl`)
197
+ is `lstat`ed first; anything but a regular file is **refused unopened**.
198
+ The open itself is `O_RDONLY|O_NOFOLLOW|O_NONBLOCK` and the descriptor is
199
+ `fstat`ed: it must be a regular file with the same device+inode lstat saw
200
+ (closes the lstat→open swap). At most the last 4 MiB is read by descriptor.
201
+ **Canonical source shape**: `{path: "home"|"workspace", status: "ok"|"absent"|"refused"|"tail", bytes}`
202
+ — `"tail"` means only the last 4 MiB was read (partial first line dropped);
203
+ there is no separate `tail` boolean.
204
+ - **Row fields.** `incarnation` is the writing home's `instance.json.createdAt`
205
+ (ISO) or `null` for rows written before the tag; the result's top-level
206
+ `incarnation` is the current home's `createdAt` or `null` if unreadable. A
207
+ row with `incarnation: null` never matches the current incarnation, so it
208
+ cannot contribute a current waiting claim. **An unknown current incarnation
209
+ (top-level `incarnation: null`) admits NO claim**: `waitingOnYou: null`,
210
+ `waitingClaims: []`, rows still returned as history. A consumer must refuse
211
+ a null-incarnation response that nevertheless carries claims. Dedup identity is
212
+ `producer|at|kind|incarnation|data`.
213
+ - **`waitingClaims[]` row shape**: `{producer: string, waiting: boolean, since: ISO, reason: string|null}`
214
+ — one row per producer with a claim in the current incarnation, INCLUDING
215
+ cleared ones (`waiting: false`, `reason: null`, `since` = the clearing row's
216
+ `at`). `waitingOnYou` = the newest `waiting: true` row or `null`.
217
+ - **Address history.** `--home <abs>` must be a home of exactly `<instance>` under
218
+ the scope (`E_HOME_MISMATCH` otherwise, like K1). Rows whose `instance`/`home`
219
+ are not the admitted address are dropped and counted (`integrity.foreignRows`).
220
+ Every row carries `incarnation` (the writing home's `instance.json.createdAt`);
221
+ the result echoes the current home's `incarnation`. Rows tagged with an earlier
222
+ incarnation ARE returned — they are this address's history — so a consumer can
223
+ label them "earlier instance at this address". No current home → no read
224
+ (archived access is a separate contract).
225
+ - **`waitingOnYou` is a producer STATE for the current incarnation**, decided per
226
+ producer by that producer's latest row that carries the field: an explicit
227
+ `false` clears, a row without the field does not; earlier incarnations never
228
+ contribute; computed over the FULL admitted read (a `--limit` window cannot
229
+ hide a clear). `waitingClaims[]` lists every producer's current claim
230
+ (`{producer, waiting, since, reason}`); `waitingOnYou` is the newest positive.
231
+ Still `null` today — no producer emits it.
232
+ - **Provenance and corruption never disappear.** Dedup is by
233
+ `producer|at|kind|incarnation|data` (the same facts from two producers are two
234
+ rows). `integrity.unreadableRows` counts torn/invalid lines regardless of
235
+ `--since` or the window. `count` = admitted rows after `--since`, `returned` =
236
+ the window, `truncated` = window cut OR any source read as a tail.
237
+
238
+ ```
239
+ {"eventsApi":2,"instance":"dev-1","home":"/abs/home","incarnation":"<iso>","count":7,"returned":7,"truncated":false,
240
+ "integrity":{"unreadableRows":0,"foreignRows":0,"sources":[{"path":"home","status":"ok","bytes":1234},{"path":"workspace","status":"ok","bytes":1234}]},
241
+ "events":[{"eventsApi":2,"at":"<iso>","instance":"dev-1","home":"/abs/home","incarnation":"<iso>","producer":"kernel","kind":"spawned","data":{...}}, ...],
242
+ "lastEvent":{"kind":"launched","at":"<iso>","producer":"kernel","incarnation":"<iso>"},
243
+ "waitingOnYou":null,"waitingClaims":[],"notes":[...]}
244
+ ```
245
+
246
+ Desktop passes `--limit` (50|100|200) only; `--since` remains a human flag.
247
+
248
+ ## Schedule run history (`scheduleApi: 2`, `scheduleHistoryApi: 2` → **3**, OATS 0.24.8+) — K8
189
249
 
190
250
  `oats schedule show|list --json` entries gain **`recentRuns`**: the last 50
191
251
  settled runs (newest first) — every `lastRun` the scheduler recorded once its
@@ -199,6 +259,67 @@ Schedules view (frame 08) renders `recentRuns` as the recent-runs list and the
199
259
  transcript pointer as the handoff; captured-policy definitions are preserved
200
260
  as they are (definition fields are untouched by this addition).
201
261
 
262
+ ### History API 3 (`scheduleHistoryApi: 3`, feature `schedule-read-2`, OATS 0.24.13+) — K8b
263
+
264
+ Gate a Desktop history read on **both** `scheduleHistoryApi === 3` and
265
+ `"schedule-read-2"` in `features[]` (`scheduleApi` stays 2 — mutation verbs are
266
+ unchanged). API 2's reader keyed runs by outcome, read state files whole and
267
+ unchecked, echoed a stored `definition.id` without checking it, and named a
268
+ `transcript` that no reader backs. API 3:
269
+
270
+ - **Run identity is time, not outcome.** `runId = sha256(scheduledFor|startedAt|attemptId)[0:24]`.
271
+ A run's later facts update its one row; `transitions[]` keeps the outcome
272
+ sequence (`["started","unknown","ended"]`); `settled: boolean`
273
+ (`pending: true` is never settled); `recordedAt`. Pre-API-3 rows are returned
274
+ with `runId: null, legacy: true, settled: null, transitions: null` and are
275
+ never merged. `lastRun` carries the same `runId` as its history row.
276
+ - **Bounded, descriptor-safe state.** `oats-schedules.json` and
277
+ `.agents/schedules/state.json` are `lstat`ed (regular file only), opened
278
+ `O_NOFOLLOW|O_NONBLOCK`, `fstat`-verified (dev+ino), and read whole **only
279
+ within a 1 MiB budget** — over budget is a typed `E_SCHEDULE_STATE_OVERSIZE`
280
+ refusal with `details.source`, never truncated JSON. `list`/`show` carry
281
+ `integrity: {sources: [{path: "definitions"|"state", status: "ok"|"absent"|"refused"|"oversize"|"corrupt", bytes}]}`.
282
+ History is capped at 50 rows **at read** (`history: {status, stored, truncated}`);
283
+ one job's corrupt history (`history.status: "corrupt"`, `recentRuns: []`) or
284
+ bad identity (`unreadable: {code, message}`) never fails the other jobs in `list`.
285
+ - **Subject truth.** `list` and `show` echo `scope` (the resolved schedule-owning
286
+ workspace) and canonical `id`. A definition whose own `id` differs from its
287
+ key → `E_SCHEDULE_IDENTITY` (`details.key`, `details.declared`). IDs must match
288
+ `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` (`E_BAD_ARGS` otherwise, before any read).
289
+ - **Session provenance, never a transcript.** The `transcript` key is gone.
290
+ Each run (and `lastRun`) carries
291
+ `session: {instance: string|null, home: string|null, incarnation: string|null, server: string|null, delivery: "launched"|"delivered-active"|"none"}`
292
+ — facts the recorder had at write time (an active-session wake names the
293
+ instance only when the input result did; `incarnation` = the home's
294
+ `instance.json.createdAt` at record time; `server` = the answering peer for a
295
+ remote command). **There is no reader behind this block**: a consumer renders
296
+ provenance and a precise unavailable reason. A read-only transcript verb is a
297
+ separate seam (K12), not implied by this API.
298
+
299
+ ```
300
+ {"scope":"/abs/ws","scheduleApi":2,"scheduleHistoryApi":3,
301
+ "integrity":{"sources":[{"path":"definitions","status":"ok","bytes":812},{"path":"state","status":"ok","bytes":4410}]},
302
+ "schedules":[{"id":"nightly","scope":"/abs/ws","scheduleApi":2,"scheduleHistoryApi":3,…,
303
+ "history":{"status":"ok","stored":7,"truncated":false},
304
+ "recentRuns":[{"runId":"3f…","scheduledFor":"<iso>","startedAt":"<iso>","outcome":"ended","settled":true,"transitions":["started","ended"],"recordedAt":"<iso>",
305
+ "session":{"instance":"dev-1","home":"/abs/home","incarnation":"<iso>","server":null,"delivery":"launched"}}]}],
306
+ "scheduler":{…}}
307
+ ```
308
+
309
+ **Exact shapes (API 3):**
310
+ - `schedule list --json` → `result = {scope, scheduleApi: 2, scheduleHistoryApi: 3, integrity, schedules: Entry[], scheduler}`.
311
+ - `schedule show <id> --json` → `result = {schedule: Entry}` (one level of nesting; `integrity` is NOT on `show` — it is a scope fact reported by `list`).
312
+ - `Entry` (readable) = definition fields (`id, kind, home, message|operation, cron, enabled, …`) + `{scope, scheduleApi: 2, scheduleHistoryApi: 3, executionStatus, nextRun: ISO|null, lastRun: Run|null, history, recentRuns: Run[], running: boolean, attempt?, pendingWake?}`.
313
+ - `Entry` (unreadable, `list` only) = `{id, scope, scheduleApi: 2, scheduleHistoryApi: 3, unreadable: {code, message}, history: {status: "corrupt", stored: null, truncated: false}, recentRuns: []}` — no definition fields.
314
+ - `history` = `{status: "ok", stored: integer, truncated: boolean}` | `{status: "corrupt", stored: null, truncated: false}`.
315
+ - `Run` (API 3 row) = producer-written fields (`scheduledFor, startedAt, kind, outcome, …`) + `{runId: string, legacy: false, settled: boolean, recordedAt: ISO, transitions: string[], session}`; `transitions[]` elements are outcome strings in write order, first element = the first recorded outcome.
316
+ - `Run` (legacy row) = producer-written fields + `{runId: null, legacy: true, settled: null, transitions: null, session}` — no `recordedAt`, no `key`.
317
+ - `Run` (corrupt element) = `{runId: null, legacy: true, corrupt: true}` only.
318
+ - `session` = `{instance: string|null, home: string|null, incarnation: ISO|null, server: string|null, delivery: "launched"|"delivered-active"|"none"}`; always present on rows and on `lastRun`.
319
+ - `runId` is opaque to consumers: never recompute, never dedup client-side.
320
+ - **Refusals** (`ok:false`): `E_SCHEDULE_STATE_OVERSIZE` / `E_SCHEDULE_INVALID` carry `error.details.source = {path, status, bytes}` (+ `field`); `E_SCHEDULE_IDENTITY` carries `error.details.key` and `error.details.declared`; `E_BAD_ARGS` (id shape) carries no details. A refusal has no `integrity` block — `list` refuses as a whole only when a scope file itself is unreadable.
321
+ - **Open path** (both files): `lstat` → regular file → `open(O_RDONLY|O_NOFOLLOW|O_NONBLOCK)` → `fstat` regular + same dev/ino + `size ≤ 1 MiB` → read exactly `fstat.size` bytes by descriptor (a file that grows past the budget between lstat and fstat is refused, never partially read).
322
+
202
323
  ## Spawn preview (`oats spawn … --preview`, `spawnPreviewApi: 1`, OATS 0.24.8+)
203
324
 
204
325
  The Spawn modal's fields are backed by the kernel's own decision, taken **before
@@ -0,0 +1,57 @@
1
+ # OATS v0.24.12 — instance events API 2 and the Desktop activity popover
2
+
3
+ Kernel/Pi/Desktop **0.24.12**. Tag `v0.24.12` → 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 — `instance-events-2` (`eventsApi: 2`)
8
+
9
+ `oats instance events` was a working reader with four defects found by the
10
+ Desktop engineer's exact-source review; each is closed under a new advertised
11
+ name because a strengthened guarantee must be distinguishable from the installed
12
+ CLI that lacks it.
13
+
14
+ - **Bounded, descriptor-safe read.** Each log source is `lstat`ed first; anything
15
+ but a regular file is refused unopened. The open is `O_NOFOLLOW|O_NONBLOCK` and
16
+ the descriptor is `fstat`ed against lstat's device+inode (closes the
17
+ lstat→open swap). At most the last 4 MiB is read by descriptor
18
+ (`integrity.sources[].status: ok|absent|refused|tail`).
19
+ - **Address history, incarnation-tagged.** `--home` must be a home of exactly
20
+ the named instance (`E_HOME_MISMATCH`). Rows of another address are dropped and
21
+ counted (`integrity.foreignRows`). Every row carries `incarnation` (the writing
22
+ home's `instance.json.createdAt`); the result echoes the current one, so a
23
+ recreated same-address instance's earlier rows are visible *as earlier*.
24
+ - **`waitingOnYou` is a producer state**, per (producer, current incarnation):
25
+ that producer's latest row carrying the field decides, an explicit `false`
26
+ clears, earlier incarnations never contribute, and it is computed over the full
27
+ admitted read so a `--limit` window cannot hide a clear. `waitingClaims[]`
28
+ shows every producer's current claim. **An unknown current incarnation
29
+ (unreadable `instance.json`) admits no claim at all.** Still `null` today — no
30
+ producer emits it.
31
+ - **Provenance and corruption never disappear.** Dedup includes the producer;
32
+ `integrity.unreadableRows` counts torn lines regardless of `--since` or the
33
+ window; `count`/`returned`/`truncated` are consistent.
34
+
35
+ ## Kernel — retirement fingerprint scope (K6h)
36
+
37
+ 0.24.11's kernel-field neutrality for `instance.json` — and the pre-existing
38
+ `.oats-*` receipt exclusions — were keyed on a root-level *filename*, so they
39
+ also applied to directory work trees and recovery trees. Both are now opt-in
40
+ (`{ instanceHome: true }`), passed by exactly the four instance-home callers;
41
+ work and recovery trees are fully significant. A comparison blind spot (directory
42
+ work was already always recovered when non-empty), not a deletion.
43
+
44
+ ## Desktop
45
+
46
+ - **Reported lifecycle activity** in the selected-instance popover: an explicit
47
+ *Load / Refresh activity* control (no roster or per-card fan-out) shows the
48
+ kernel's dated events with source, integrity (refused/tail/torn/foreign
49
+ visibly incomplete), earlier/unknown incarnation labels, and attributed
50
+ waiting claims as *claims as of a timestamp* — never as current runtime
51
+ authority. Gated on `eventsApi === 2` and `instance-events-2`; two coalesced
52
+ process-wide read slots; one observation retained per open popup; a failed
53
+ refresh keeps visibly stale rows rather than a false empty.
54
+
55
+ ## Upgrade
56
+
57
+ `npm i -g @awebai/oats@0.24.12`, then `oats doctor`.
@@ -0,0 +1,51 @@
1
+ # OATS v0.24.13 — schedule history API 3 and the Desktop schedules table
2
+
3
+ Kernel/Pi/Desktop **0.24.13**. Tag `v0.24.13` → 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 — `schedule-read-2` (`scheduleHistoryApi: 3`; `scheduleApi` stays 2)
8
+
9
+ `oats schedule list|show` history had four defects found by the Desktop
10
+ engineer's exact-source review; each is closed under a new advertised name.
11
+
12
+ - **A run's identity is when it was scheduled and started, never its outcome.**
13
+ `runId = sha256(scheduledFor|startedAt|attemptId)[0:24]`; a run's later facts
14
+ update its one row; `transitions[]` keeps the outcome sequence
15
+ (`["started","unknown","ended"]` is one run, not three); `settled`,
16
+ `recordedAt`. Pre-API-3 rows are returned `legacy: true` with `runId: null`
17
+ and are never merged.
18
+ - **Bounded, descriptor-safe state.** Both scope files are `lstat`ed (regular
19
+ file only), opened `O_NOFOLLOW|O_NONBLOCK`, `fstat`-verified (dev+ino) and
20
+ read whole **only within a 1 MiB budget** — over budget is a typed
21
+ `E_SCHEDULE_STATE_OVERSIZE` refusal, never truncated JSON. `list` carries
22
+ `integrity.sources[]`; history is capped at 50 rows at read
23
+ (`history: {status, stored, truncated}`); one job's corrupt history or bad
24
+ identity is its own row and never fails the others.
25
+ - **Subject truth.** `list`/`show` echo the resolved `scope` and canonical `id`;
26
+ a definition whose own `id` differs from its key → `E_SCHEDULE_IDENTITY`; ids
27
+ are validated before any read. Typed refusal details travel through the CLI's
28
+ JSON failure.
29
+ - **Session provenance, never a transcript.** The `transcript` pointer named a
30
+ reader that does not exist and is gone. Every run carries
31
+ `session: {instance|null, home|null, incarnation|null, server|null, delivery}`
32
+ — what the recorder knew at write time. A read-only transcript verb is a
33
+ separate seam (K12), not implied by this API.
34
+
35
+ ## Desktop
36
+
37
+ - **Schedules** view: the compact table (enabled, schedule, target, cadence,
38
+ next run, last reported outcome, actions) with **Recent runs** for the
39
+ workspace (≤50), read once on entry and on explicit Refresh — no polling.
40
+ History enables nothing; `ended` is not success and `delivered` is not
41
+ consumption; missing time facts are shown as unreported. Session provenance
42
+ is displayed with a precise unavailable reason — no transcript, terminal or
43
+ link. Remote history and editing a captured wake are shown as honest
44
+ limitations. Gated on `scheduleHistoryApi === 3` and `schedule-read-2`.
45
+ - **Security**: the command-running `GET /api/schedules` is removed (405, no
46
+ command, no default workspace); legacy POST `list|show` aliases run through
47
+ the same strict admission, budgets and two coalesced read slots.
48
+
49
+ ## Upgrade
50
+
51
+ `npm i -g @awebai/oats@0.24.13`, then `oats doctor`.
package/lib/core.mjs CHANGED
@@ -73,6 +73,8 @@ import { invokeProviderBinding } from "./provider-binding-broker.mjs";
73
73
  import { withCapturedBindingFile } from "./captured-binding-file.mjs";
74
74
  import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
75
75
  export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
76
+ /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
77
+ export { fingerprintTree };
76
78
  import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
77
79
  import { createCapabilityMaterializer } from "./package-materialization.mjs";
78
80
  import { resolvePackageClosure } from "./package-closure.mjs";
@@ -7257,7 +7259,7 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
7257
7259
  * otherwise every stop or event write would read as "changed home bytes". */
7258
7260
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
7259
7261
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
7260
- function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
7262
+ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
7261
7263
  const hash = createHash("sha256");
7262
7264
  const rootStat = lstatSync(root);
7263
7265
  if (!rootStat.isDirectory()) {
@@ -7269,13 +7271,13 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
7269
7271
  }
7270
7272
  const walk = (dir, rel = "") => {
7271
7273
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
7272
- if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
7274
+ if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
7273
7275
  const childRel = rel ? join(rel, e.name) : e.name;
7274
7276
  const path = join(dir, e.name);
7275
7277
  const st = lstatSync(path);
7276
7278
  hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
7277
7279
  if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(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"); }
7280
+ else if (st.isFile()) { hash.update("file\0"); hash.update(instanceHome && !rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); /* kernel-field neutrality applies ONLY when the tree IS an instance home; a work tree's instance.json is the agent's bytes */ hash.update("\0"); }
7279
7281
  else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
7280
7282
  else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
7281
7283
  }
@@ -7360,7 +7362,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
7360
7362
  ...(incarnationId ? { incarnationId, executionBinding } : {}),
7361
7363
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
7362
7364
  home: realPathOrNearest(home),
7363
- homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
7365
+ homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
7364
7366
  disposableReceipts,
7365
7367
  generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
7366
7368
  runtime: {
@@ -8453,7 +8455,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8453
8455
  const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
8454
8456
  if (!baselineValid) {
8455
8457
  classes.push("unknown instance-home provenance");
8456
- } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
8458
+ } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
8457
8459
  classes.push("changed instance-home bytes");
8458
8460
  }
8459
8461
  // A mutable mode must not turn owned directory bytes into an excluded shared
@@ -8478,7 +8480,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8478
8480
  if (nestedGitRoots(work).length) classes.push("nested repository state");
8479
8481
  }
8480
8482
  const stateFingerprint = createHash("sha256")
8481
- .update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
8483
+ .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
8482
8484
  .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
8483
8485
  .digest("hex");
8484
8486
  return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
@@ -8604,7 +8606,7 @@ function preserveRetirementWork(observation, meta, instance) {
8604
8606
  }
8605
8607
  const recoveredHome = join(staging, "home");
8606
8608
  copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
8607
- if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
8609
+ if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
8608
8610
  throw new Error("home recovery verification disagreed with the source");
8609
8611
  }
8610
8612
  let branchDrift;
@@ -5,11 +5,24 @@
5
5
  * receipt it produced. Nothing here is inferred from transcripts, task files or
6
6
  * prose; "waiting on you" is reported only when a producer says so — today no
7
7
  * producer does, and the view says `null`, not a guess. Retirement's event
8
- * outlives the home in the workspace-level log. */
9
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
8
+ * outlives the home in the workspace-level log.
9
+ *
10
+ * eventsApi 2 (`instance-events-2`) — the read is BOUNDED and ADDRESSED:
11
+ * - a source is `lstat`ed first and opened only if it is a regular file; the
12
+ * reader reads at most the last MAX_BYTES by descriptor, never the whole file;
13
+ * - rows are the ADDRESS's history: a row whose instance/home is not the
14
+ * admitted address is dropped and counted (`integrity.foreignRows`), a torn
15
+ * line is counted (`integrity.unreadableRows`) — never sorted or windowed away;
16
+ * - every row is tagged with the `incarnation` (the home's instance.json
17
+ * `createdAt`) that wrote it; the result echoes the current one, so a
18
+ * recreated same-address instance's earlier rows are visible as earlier;
19
+ * - `waitingOnYou` is a producer STATE per (producer, current incarnation):
20
+ * that producer's latest row carrying the field decides, an explicit `false`
21
+ * clears, and it is computed over the full admitted read, before windowing. */
22
+ import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
10
23
  import { basename, dirname, join } from "node:path";
11
24
 
12
- export const EVENTS_API = 1;
25
+ export const EVENTS_API = 2;
13
26
  const HOME_LOG = ".oats-events.jsonl";
14
27
  const MAX_BYTES = 4 * 1024 * 1024;
15
28
 
@@ -23,11 +36,16 @@ function workspaceLogPath(home) {
23
36
  return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
24
37
  }
25
38
 
39
+ /** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
40
+ export function incarnationOf(home) {
41
+ try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; }
42
+ }
43
+
26
44
  /** Append one typed event. Never throws into the caller's action: an event is
27
45
  * evidence, not authority — a failed write is reported in the return value. */
28
- export function appendEvent(home, event, { workspaceOnly = false } = {}) {
46
+ export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
29
47
  if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
30
- const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
48
+ const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
31
49
  const line = JSON.stringify(row) + "\n";
32
50
  const results = [];
33
51
  // Retirement fingerprints the home against its baseline and preserves any
@@ -44,33 +62,81 @@ export function appendEvent(home, event, { workspaceOnly = false } = {}) {
44
62
  return { ok: results.some((r) => r.ok), row, results };
45
63
  }
46
64
 
47
- function readLog(path) {
48
- if (!existsSync(path)) return { rows: [], truncated: false, bytes: 0 };
49
- const size = statSync(path).size;
50
- const text = readFileSync(path, "utf8");
51
- const rows = [];
52
- for (const line of text.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r && r.eventsApi === EVENTS_API && typeof r.kind === "string") rows.push(r); } catch { /* a torn line is skipped, counted */ rows.push({ eventsApi: EVENTS_API, kind: "unreadable", at: null, producer: "kernel", data: { reason: "torn or invalid line" } }); } }
53
- return { rows, truncated: size > MAX_BYTES, bytes: size };
65
+ /** Bounded, descriptor-safe read of one log: lstat → regular file only → read
66
+ * at most the last MAX_BYTES. Returns rows plus the source's integrity facts. */
67
+ function readLog(path, label) {
68
+ let st;
69
+ try { st = lstatSync(path); } catch (e) { return e.code === "ENOENT" ? { rows: [], unreadable: 0, source: { path: label, status: "absent", bytes: 0 } } : { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
70
+ if (!st.isFile()) return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; // symlink, FIFO, device, directory: never opened
71
+ let fd;
72
+ try {
73
+ // Close the lstat→open swap: open WITHOUT following a symlink and without
74
+ // blocking on a FIFO, then verify by descriptor that what was opened is the
75
+ // regular file lstat saw (same device+inode). Anything else is refused.
76
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
77
+ const fst = fstatSync(fd);
78
+ if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino) throw new Error("swapped");
79
+ st = fst; // the size of what is actually open
80
+ } catch { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
81
+ const tail = st.size > MAX_BYTES;
82
+ const length = tail ? MAX_BYTES : st.size;
83
+ const buf = Buffer.alloc(length);
84
+ try {
85
+ let got = 0; while (got < length) { const n = readSync(fd, buf, got, length - got, (tail ? st.size - MAX_BYTES : 0) + got); if (n <= 0) break; got += n; }
86
+ } catch { return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: st.size } }; }
87
+ finally { try { closeSync(fd); } catch { /* nothing to recover */ } }
88
+ const text = buf.toString("utf8");
89
+ const lines = text.split("\n");
90
+ if (tail) lines.shift(); // the first line of a tail is (almost surely) partial; it is not counted as torn — the tail itself is reported
91
+ const rows = []; let unreadable = 0;
92
+ for (const line of lines) {
93
+ if (!line.trim()) continue;
94
+ try { const r = JSON.parse(line); if (r && typeof r === "object" && Number.isInteger(r.eventsApi) && r.eventsApi >= 1 && typeof r.kind === "string" && typeof r.at === "string") rows.push(r); else unreadable++; } catch { unreadable++; }
95
+ }
96
+ return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
54
97
  }
55
98
 
56
- /** Events for one instance, newest last, from both logs (deduplicated by
57
- * at+kind), with a bounded window. `waitingOnYou` is a producer claim or null. */
99
+ /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
100
+ * window; integrity is reported independently of the selected rows. */
58
101
  export function readEvents(home, { limit = 200, since = null } = {}) {
59
- const a = readLog(join(home, HOME_LOG)), b = readLog(workspaceLogPath(home));
60
- const seen = new Set(); const rows = [];
61
- for (const r of [...a.rows, ...b.rows]) { const k = `${r.at}|${r.kind}|${JSON.stringify(r.data ?? null)}`; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
62
- rows.sort((x, y) => String(x.at ?? "").localeCompare(String(y.at ?? "")));
63
- const filtered = since ? rows.filter((r) => r.at && r.at > since) : rows;
102
+ const instance = basename(home);
103
+ const incarnation = incarnationOf(home);
104
+ const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
105
+ const seen = new Set(); const rows = []; let foreign = 0;
106
+ for (const r of [...a.rows, ...b.rows]) {
107
+ if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
108
+ const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
109
+ if (seen.has(k)) continue; seen.add(k); rows.push({ ...r, incarnation: r.incarnation ?? null });
110
+ }
111
+ rows.sort((x, y) => x.at.localeCompare(y.at));
112
+ // Waiting: a producer STATE for the CURRENT incarnation, decided by that
113
+ // producer's latest row that carries the field, over the FULL admitted read.
114
+ // An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
115
+ // unknown stays unknown, it never resurrects an earlier incarnation's positive.
116
+ const claims = new Map();
117
+ for (const r of (incarnation === null ? [] : rows)) {
118
+ if (r.incarnation !== incarnation) continue;
119
+ if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
120
+ const p = r.producer ?? "kernel";
121
+ claims.set(p, r.data.waitingOnYou ? { producer: p, waiting: true, since: r.at, reason: typeof r.data.reason === "string" ? r.data.reason : null } : { producer: p, waiting: false, since: r.at, reason: null });
122
+ }
123
+ const waitingClaims = [...claims.values()];
124
+ const positive = waitingClaims.filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
125
+ const filtered = since ? rows.filter((r) => r.at > since) : rows;
64
126
  const window = filtered.slice(-limit);
65
127
  const last = window.at(-1) ?? null;
66
- const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
67
128
  return {
68
- eventsApi: EVENTS_API, instance: basename(home), home, count: filtered.length, returned: window.length, truncated: filtered.length > window.length || a.truncated || b.truncated,
69
- events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer } : null,
70
- waitingOnYou: waiting ? { since: waiting.at, producer: waiting.producer, reason: waiting.data.reason ?? null } : null,
129
+ eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
130
+ truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
131
+ integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
132
+ events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
133
+ waitingOnYou: positive ? { since: positive.since, producer: positive.producer, reason: positive.reason } : null,
134
+ waitingClaims,
71
135
  notes: [
72
136
  "events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
73
- "waitingOnYou is null unless a producer reported it — null means unknown, not 'not waiting'",
137
+ "this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
138
+ "waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
139
+ "integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
74
140
  ],
75
141
  };
76
142
  }
package/lib/schedule.mjs CHANGED
@@ -20,7 +20,8 @@
20
20
  * succeeded. A launch whose side effects cannot be confirmed stays
21
21
  * `unknown` with its slot held until `reconcile` proves what happened. */
22
22
  import { execFileSync, spawnSync } from "node:child_process";
23
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { createHash } from "node:crypto";
24
25
  import { homedir } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
@@ -127,11 +128,32 @@ export function stateDir(ws) { return join(ws, ".agents", "schedules"); }
127
128
  function statePath(ws) { return join(stateDir(ws), "state.json"); }
128
129
  function lockDir(ws, id) { return join(stateDir(ws), "locks", id); }
129
130
 
130
- function readJson(path, fallback) {
131
- if (!existsSync(path)) return fallback;
132
- try { return JSON.parse(readFileSync(path, "utf8")); }
133
- catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file" }); }
131
+ export const SCHEDULE_HISTORY_API = 3;
132
+ export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
133
+ /** K8b — bounded, descriptor-safe JSON read: lstat → regular file only →
134
+ * O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
135
+ * within budget (an over-budget file is REFUSED as a typed error, never
136
+ * truncated into fabricated JSON). Returns { value, source }. */
137
+ function readJsonBounded(path, fallback, label) {
138
+ let st;
139
+ try { st = lstatSync(path); }
140
+ catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
141
+ if (!st.isFile()) throw scheduleError("E_SCHEDULE_INVALID", `${path} is not a regular file`, { field: "file", source: { path: label, status: "refused", bytes: 0 } });
142
+ if (st.size > SCHEDULE_FILE_BUDGET) throw scheduleError("E_SCHEDULE_STATE_OVERSIZE", `${path} is ${st.size} bytes; the read budget is ${SCHEDULE_FILE_BUDGET}`, { field: "file", source: { path: label, status: "oversize", bytes: st.size } });
143
+ let fd, text;
144
+ try {
145
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
146
+ const fst = fstatSync(fd);
147
+ if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
148
+ const buf = Buffer.alloc(fst.size); let got = 0;
149
+ while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
150
+ text = buf.toString("utf8"); st = fst;
151
+ } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read safely: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: st.size } }); }
152
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } }
153
+ try { return { value: JSON.parse(text), source: { path: label, status: "ok", bytes: st.size } }; }
154
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file", source: { path: label, status: "corrupt", bytes: st.size } }); }
134
155
  }
156
+ function readJson(path, fallback) { return readJsonBounded(path, fallback, basename(path)).value; }
135
157
  function writeJson(path, value) {
136
158
  mkdirSync(dirname(path), { recursive: true });
137
159
  const tmp = `${path}.tmp-${process.pid}`;
@@ -139,27 +161,52 @@ function writeJson(path, value) {
139
161
  renameSync(tmp, path);
140
162
  }
141
163
  export function readDefinitions(ws) {
142
- const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
164
+ const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
143
165
  if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
144
166
  return doc;
145
167
  }
146
168
  export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
147
- export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
169
+ export function readState(ws) { const st = readJsonBounded(statePath(ws), { jobs: {} }, "state").value; if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
170
+ /** Integrity facts for a scope's two files, without throwing. */
171
+ export function scheduleIntegrity(ws) {
172
+ const sources = [];
173
+ for (const [path, label, fallback] of [[definitionsPath(ws), "definitions", { version: 1, jobs: {} }], [statePath(ws), "state", { jobs: {} }]]) {
174
+ try { sources.push(readJsonBounded(path, fallback, label).source); }
175
+ catch (e) { sources.push(e.source ?? { path: label, status: "refused", bytes: 0 }); }
176
+ }
177
+ return { sources };
178
+ }
148
179
  /** K8 — bounded run history. Every time a job's lastRun reaches a settled
149
180
  * outcome (not "active"/"starting"), it is appended once to `recentRuns`
150
181
  * (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
151
182
  * same run never duplicate it. Recording happens on save: producers keep
152
183
  * writing lastRun exactly as they do; nothing is inferred. */
153
184
  export const RECENT_RUNS_MAX = 50;
154
- function recordRecentRuns(st) {
185
+ /** K8b — a run's identity is WHEN it was scheduled and started (plus the
186
+ * attempt id when one exists), never its outcome: unknown→ended is one run
187
+ * with two transitions, not two rows. */
188
+ export function runIdOf(lr) {
189
+ if (!lr || typeof lr !== "object" || (!lr.scheduledFor && !lr.startedAt)) return null;
190
+ return createHash("sha256").update(`${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.execution?.attemptId ?? lr.attemptId ?? ""}`).digest("hex").slice(0, 24);
191
+ }
192
+ const SETTLED_OUTCOMES = new Set(["ended", "stopped", "delivered", "skipped", "blocked", "invalid", "failed", "unknown", "refused", "completed"]);
193
+ function recordRecentRuns(st, now = new Date()) {
155
194
  for (const js of Object.values(st.jobs || {})) {
156
195
  const lr = js.lastRun;
157
196
  if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
158
- const key = `${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.outcome ?? ""}`;
197
+ const runId = runIdOf(lr); if (!runId) continue;
159
198
  js.recentRuns ||= [];
160
- if (js.recentRuns[0]?.key === key) { js.recentRuns[0] = { key, ...lr }; continue; } // same run, later fields
161
- if (js.recentRuns.some((r) => r.key === key)) continue;
162
- js.recentRuns.unshift({ key, ...lr });
199
+ const settled = SETTLED_OUTCOMES.has(lr.outcome) && lr.pending !== true;
200
+ const existing = js.recentRuns.find((r) => r.runId === runId);
201
+ if (existing) {
202
+ // same run, later facts: update in place, keep the outcome sequence
203
+ const prev = existing.outcome;
204
+ Object.assign(existing, lr, { runId, settled, recordedAt: now.toISOString() });
205
+ existing.transitions = [...(existing.transitions || [prev]), ...(prev !== lr.outcome ? [lr.outcome] : [])];
206
+ continue;
207
+ }
208
+ // legacy rows (pre-K8b, keyed by outcome) are left as they are: never merged, reported as legacy
209
+ js.recentRuns.unshift({ runId, ...lr, settled, recordedAt: now.toISOString(), transitions: [lr.outcome] });
163
210
  if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
164
211
  }
165
212
  }
@@ -502,7 +549,10 @@ function launchCommand(ws, def, io) {
502
549
  const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
503
550
  return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
504
551
  }
505
- return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
552
+ // Provenance the recorder has at write time: the answering server (remote
553
+ // spawn) and the launched home's incarnation. Never a transcript.
554
+ const server = typeof res.server === "string" ? res.server : undefined;
555
+ return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home, incarnation: incarnationOfHome(home) } : {}), ...(server ? { server } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
506
556
  }
507
557
 
508
558
  /** Verify an immutable capsule and its current exact-artifact authorization
@@ -596,12 +646,16 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
596
646
  if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
597
647
  try {
598
648
  const r = (io?.start || startInstanceSession)(def.home);
599
- return { kind: "wake", action: "started", instance: r.instance, target: r.target, reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
649
+ return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
600
650
  } catch (e) {
601
651
  return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
602
652
  }
603
653
  }
604
- try { (io?.input || inputInstanceSession)(def.home, def.message); return { kind: "wake", action: "delivered", pending: false }; }
654
+ try {
655
+ const r = (io?.input || inputInstanceSession)(def.home, def.message);
656
+ // Delivery custody: name the instance ONLY when the input result names it; the home's current incarnation at record time.
657
+ return { kind: "wake", action: "delivered", pending: false, ...(typeof r?.instance === "string" ? { instance: r.instance } : {}), home: def.home, incarnation: incarnationOfHome(def.home) };
658
+ }
605
659
  catch (e) { return { kind: "wake", action: "skipped", reason: `input refused: ${e.message}`, pending: true }; }
606
660
  }
607
661
 
@@ -887,20 +941,52 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
887
941
 
888
942
  // -------------------------------------------------------- definitions CRUD
889
943
 
944
+ export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
945
+ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; } }
946
+ /** K8b — session PROVENANCE of a run (facts the recorder had), never a
947
+ * transcript: there is no reader behind this block. */
948
+ function sessionProvenanceOf(r) {
949
+ if (!r || typeof r !== "object") return null;
950
+ const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
951
+ return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
952
+ }
953
+ /** Read-time projection of one job's stored history: bounded to
954
+ * RECENT_RUNS_MAX, legacy rows marked, corruption isolated to the job. */
955
+ function projectHistory(js) {
956
+ const stored = js?.recentRuns;
957
+ if (stored === undefined || stored === null) return { history: { status: "ok", stored: 0, truncated: false }, recentRuns: [] };
958
+ if (!Array.isArray(stored)) return { history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] };
959
+ const rows = [];
960
+ for (const r of stored.slice(0, RECENT_RUNS_MAX)) {
961
+ if (!r || typeof r !== "object") { rows.push({ runId: null, legacy: true, corrupt: true }); continue; }
962
+ const { key, ...rest } = r;
963
+ const legacy = typeof r.runId !== "string";
964
+ rows.push({ ...rest, runId: legacy ? null : r.runId, legacy, ...(legacy ? { settled: null, transitions: null } : {}), session: sessionProvenanceOf(rest) });
965
+ }
966
+ return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
967
+ }
890
968
  export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
969
+ if (typeof id !== "string" || !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(id)})`);
891
970
  defs ||= readDefinitions(ws); st ||= readState(ws);
892
971
  const def = defs.jobs[id];
893
972
  if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
973
+ // Subject truth: the definition's own id must be the key it is stored under.
974
+ if (def.id !== undefined && def.id !== id) throw scheduleError("E_SCHEDULE_IDENTITY", `schedule stored under ${JSON.stringify(id)} declares id ${JSON.stringify(def.id)}`, { key: id, declared: def.id });
894
975
  const js = st.jobs[id] || {};
895
976
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
896
977
  const lock = jobLockInfo(ws, id);
897
978
  const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
898
- const recentRuns = (js.recentRuns || []).map(({ key, ...r }) => ({ ...r, ...(r.instance && r.home ? { transcript: { instance: r.instance, home: r.home, kind: "session" } } : {}) }));
899
- return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
979
+ const { history, recentRuns } = projectHistory(js);
980
+ return { ...def, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
900
981
  }
901
982
  export function listSchedules(ws, io, { now = new Date() } = {}) {
902
983
  const defs = readDefinitions(ws), st = readState(ws);
903
- return { schedules: Object.keys(defs.jobs).sort().map((id) => describe(ws, id, io, { defs, st, now })), scheduler: schedulerStatus(ws, io) };
984
+ // One job's bad identity or history does not fail the others: it is reported as its own row.
985
+ const schedules = Object.keys(defs.jobs).sort().map((id) => {
986
+ try { return describe(ws, id, io, { defs, st, now }); }
987
+ catch (e) { return { id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, unreadable: { code: e.code || "E_SCHEDULE_INVALID", message: e.message }, history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] }; }
988
+ });
989
+ return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
904
990
  }
905
991
  export function addSchedule(ws, spec, io) {
906
992
  const def = validateDefinition(ws, spec);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.11",
3
+ "version": "0.24.13",
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",