@awebai/oats 0.24.10 → 0.24.12
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 +7 -6
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/desktop-cli-api.md +61 -1
- package/docs/release-notes/v0.24.11.md +30 -0
- package/docs/release-notes/v0.24.12.md +57 -0
- package/lib/core.mjs +25 -20
- package/lib/instance-events.mjs +90 -24
- package/package.json +1 -1
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,
|
|
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";
|
|
@@ -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
|
-
|
|
3148
|
-
|
|
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})` : ""}`);
|
|
@@ -4358,10 +4359,10 @@ function spawnCmd() {
|
|
|
4358
4359
|
// K6e: record the wake outcome in the home so a same-key replay can report
|
|
4359
4360
|
// it instead of leaving "saved or not?" to inference.
|
|
4360
4361
|
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;
|
|
4362
|
+
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
|
}
|
|
4363
4364
|
} 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;
|
|
4365
|
+
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 */ }
|
|
4365
4366
|
}
|
|
4366
4367
|
if (JSON_MODE) {
|
|
4367
4368
|
// Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
|
|
@@ -5033,7 +5034,7 @@ function versionCmd() {
|
|
|
5033
5034
|
// on it (an older CLI without the surface must fail closed with a
|
|
5034
5035
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
5035
5036
|
// 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:
|
|
5037
|
+
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", "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: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
5037
5038
|
return;
|
|
5038
5039
|
}
|
|
5039
5040
|
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:40Z · K7b on main (PR89 `e808fbfc`; PR90 `17182cdb` open-time O_NOFOLLOW+fstat guard + DTO pins) · **K6h PR91 → main `dcd2bc89`**: kernel-field neutrality + receipt exclusions now opt-in for instance homes only (work/recovery trees fully significant) · **7b wiring head = `dcd2bc89`** · engineer wiring 7b → 8 · 0.24.12 after 7b Desktop PR · open: 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
|
@@ -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
|
|
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,6 +185,66 @@ 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
|
+
### 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
|
+
|
|
188
248
|
## Schedule run history (`scheduleApi: 2`, OATS 0.24.8+) — K8
|
|
189
249
|
|
|
190
250
|
`oats schedule show|list --json` entries gain **`recentRuns`**: the last 50
|
|
@@ -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`.
|
|
@@ -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`.
|
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";
|
|
@@ -6898,8 +6900,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6898
6900
|
if (o.idempotencyKey !== undefined) {
|
|
6899
6901
|
// Only now is the spawn a finished receipt a same-key retry may replay.
|
|
6900
6902
|
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
|
+
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
6903
6904
|
}
|
|
6904
6905
|
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 };
|
|
6905
6906
|
} catch (error) {
|
|
@@ -7258,7 +7259,7 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
|
7258
7259
|
* otherwise every stop or event write would read as "changed home bytes". */
|
|
7259
7260
|
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
7260
7261
|
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
7261
|
-
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
7262
|
+
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
|
|
7262
7263
|
const hash = createHash("sha256");
|
|
7263
7264
|
const rootStat = lstatSync(root);
|
|
7264
7265
|
if (!rootStat.isDirectory()) {
|
|
@@ -7270,13 +7271,13 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
7270
7271
|
}
|
|
7271
7272
|
const walk = (dir, rel = "") => {
|
|
7272
7273
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7273
|
-
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;
|
|
7274
7275
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
7275
7276
|
const path = join(dir, e.name);
|
|
7276
7277
|
const st = lstatSync(path);
|
|
7277
7278
|
hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
|
|
7278
7279
|
if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
|
|
7279
|
-
else if (st.isFile()) { hash.update("file\0"); hash.update(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"); }
|
|
7280
7281
|
else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
|
|
7281
7282
|
else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
|
|
7282
7283
|
}
|
|
@@ -7335,17 +7336,21 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
7335
7336
|
return roots;
|
|
7336
7337
|
}
|
|
7337
7338
|
|
|
7338
|
-
/**
|
|
7339
|
-
*
|
|
7340
|
-
*
|
|
7341
|
-
* not read as the agent's
|
|
7342
|
-
*
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
|
|
7339
|
+
/** The fields the KERNEL writes into a home's instance.json after the spawn's
|
|
7340
|
+
* retirement baseline was taken (completion marker, wake record). The
|
|
7341
|
+
* baseline fingerprint hashes instance.json with exactly these removed, so a
|
|
7342
|
+
* kernel write does not read as the agent's change — and NOTHING ELSE in the
|
|
7343
|
+
* home is ever re-blessed: an agent's STATE.md written seconds after launch
|
|
7344
|
+
* is still recovered at retire. (Replaces a whole-home re-stamp that could
|
|
7345
|
+
* bless authored bytes written in the launch→completion interval.) */
|
|
7346
|
+
const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
|
|
7347
|
+
function kernelNeutralInstanceJson(bytes) {
|
|
7348
|
+
try {
|
|
7349
|
+
const m = JSON.parse(String(bytes));
|
|
7350
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
|
|
7351
|
+
for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
|
|
7352
|
+
return Buffer.from(JSON.stringify(m));
|
|
7353
|
+
} catch { return bytes; }
|
|
7349
7354
|
}
|
|
7350
7355
|
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
7351
7356
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
@@ -7357,7 +7362,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
|
|
|
7357
7362
|
...(incarnationId ? { incarnationId, executionBinding } : {}),
|
|
7358
7363
|
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
7359
7364
|
home: realPathOrNearest(home),
|
|
7360
|
-
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
|
|
7365
|
+
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
|
|
7361
7366
|
disposableReceipts,
|
|
7362
7367
|
generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
|
|
7363
7368
|
runtime: {
|
|
@@ -8450,7 +8455,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
8450
8455
|
const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
|
|
8451
8456
|
if (!baselineValid) {
|
|
8452
8457
|
classes.push("unknown instance-home provenance");
|
|
8453
|
-
} else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
|
|
8458
|
+
} else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
|
|
8454
8459
|
classes.push("changed instance-home bytes");
|
|
8455
8460
|
}
|
|
8456
8461
|
// A mutable mode must not turn owned directory bytes into an excluded shared
|
|
@@ -8475,7 +8480,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
8475
8480
|
if (nestedGitRoots(work).length) classes.push("nested repository state");
|
|
8476
8481
|
}
|
|
8477
8482
|
const stateFingerprint = createHash("sha256")
|
|
8478
|
-
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
|
|
8483
|
+
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
8479
8484
|
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
8480
8485
|
.digest("hex");
|
|
8481
8486
|
return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
@@ -8601,7 +8606,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
8601
8606
|
}
|
|
8602
8607
|
const recoveredHome = join(staging, "home");
|
|
8603
8608
|
copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
|
|
8604
|
-
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 })) {
|
|
8605
8610
|
throw new Error("home recovery verification disagreed with the source");
|
|
8606
8611
|
}
|
|
8607
8612
|
let branchDrift;
|
package/lib/instance-events.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
*
|
|
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
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
"
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.12",
|
|
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",
|