Compare commits

...
Author SHA1 Message Date
jason.woltje 6fdfe3790c gizmoduck: row 307 run logs: capture.mjs makes each fixture's data root before sessionStore (base session.mjs:78 needs it), BUILD.md expected red adds claude-history test B (duckworth-0287), steps 1-6 results on 1f6db1589 and 1f00d4f91 with skips named and M1-M4 killed (sage-3455, sage-3465) 2026-10-23 09:15:12 -05:00
jason.woltje 1f00d4f91e gizmoduck: row 307 BUILD.md: commits, old-test edits against mos-1459, new tests and the empty-session correction to test B, expected red on 1f6db1589, run plan with mos-1275 limits, mutants M1-M4 2026-10-23 08:27:32 -05:00
jason.woltje 44104adb19 gizmoduck: row 307 (#1814, Vikunja 598) harness kind gains compactions: pi's stream scan and Claude-history check move to packages/plugins/harness/pi/compactions.mjs, claude-code answers harness-unsupported, core keeps the ledger and checks each answer, readLedger reads the reason only, the launcher passes the loaded module; four old-test edits under mos-1459 2026-10-23 08:26:29 -05:00
jason.woltje 1f6db15899 gizmoduck: row 307 (#1814, Vikunja 598) new tests, red first: row 270's ledgers per reason read the same count and the next collection goes through the harness module, core refuses an answer out of contract, pi answers what base scanPi read, claude-code answers harness-unsupported, a module without compactions refuses missing-export; capture.mjs checks the literals against base 1e629c892 2026-10-23 08:26:29 -05:00
jason.woltje cb048b93ce gizmoduck: row 307 design r1 notes: seat suite whole and compactions-launch T3 run-not-skip in the gate (Duckworth C1, C2), launcher closure comment on bypassing call() (N1), line refs :301 and :302 (N2) (duckworth-0280) 2026-10-23 08:17:41 -05:00
jason.woltje 67daf64033 gizmoduck: row 307 (#1814, Vikunja 598) design: harness export compactions({ store, cursors, budget, streams, first }), interfaceVersion stays 1, core keeps the ledger, readLedger reads the reason, ledger compatibility table, four old-test edits for Mos (sage-3440) 2026-10-23 08:14:29 -05:00
16 changed files with 1021 additions and 180 deletions

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
# Row 307 (#1814, Vikunja 598): move row 270's pi compaction reading behind the harness kind
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
docs/plans/2026-10-23_harness-kind-compactions.md. Base 1e629c892. Design: DESIGN.md beside this file,
approved (static) by Duckworth on 67daf6403 (duckworth-0280), with C1, C2, N1 and N2 folded in at
cb048b93c. Mos ruled go on the four old-test edits in mos-1459, with the bounds below.
## Commits
1. 1f6db1589, red first. The two new test files and capture.mjs. Its product files are base's:
`git diff --stat 1e629c892 1f6db1589` lists only this packet, capture.mjs and the two tests.
2. 44104adb1, the build. The product files and the four old-test edits.
## What the build does
- `packages/plugins/src/kinds.mjs:52-57`: the harness kind's exports end in `"compactions"`, and the
comment gains its contract line. `interfaceVersion` stays 1 (DESIGN.md "interfaceVersion stays 1").
- New `packages/plugins/harness/pi/compactions.mjs`: base's `scanPi`, `readRange`, `digest`, `STREAM`,
`TAIL`, the chunk size, `PI_VERSION` and `claudeHistory`, unchanged in behaviour, plus the export.
`scanPi` is no longer exported and takes no defaults: its one caller passes all four arguments.
- `pi/index.mjs` re-exports it. `claude-code/index.mjs` answers harness-unsupported with version null.
- `packages/seat/src/compactions.mjs`: keeps the ledger. `readLedger` sets `unsupported` on
`r.reason === "harness-unsupported"` only (:113). `checkAnswer` (:147-164) refuses an answer out of
contract. `collectCompactions(store, { name, compactions }, opts)` (:174-205) calls the export, checks
it, and writes the same lines in the same order as base.
- `packages/cli/src/launcher.mjs`: the `compactions` closure takes the loaded module and calls the
export directly, with the N1 comment on why it skips `call()`. Both call sites pass `harness`.
- `packages/plugins/README.md`: the Harness section gains the `compactions` bullet.
## Old-test edits (mos-1459 bounds)
| File | Edit | Bound |
| --- | --- | --- |
| plugins/tests/kinds.test.mjs | `"compactions"` appended to the pinned list | :17-21, append only |
| seat/tests/compactions.test.mjs | two module imports; `collect` passes `{ name, compactions }` | :11/:21, helper only |
| seat/tests/compactions-budget.test.mjs | pi import; `collect` passes `{ name, compactions }` | :13/:24-25, helper only |
| cli/tests/compactions-launch.test.mjs | pi import; base :344's call (now :345) passes `{ name: "pi", compactions: pi }` | call only, `.count === 1` kept |
No call or expected value in those files changed. harness.test.mjs, cli helpers.mjs:133-151,
launch-routes.test.mjs:17-23 and launcher.test.mjs:694-697 are unedited.
## New tests
- `packages/seat/tests/compactions-ledger.test.mjs`:
- FIXTURES: seven ledgers row 270's code writes (known, store-incomplete, store-unreadable,
not-collected, harness-unsupported from claude-code, harness-unsupported from Claude history, and
torn), each with its streams, projects and steps. Per fixture, test A reads `compactionCount` and
test B runs one collection through `{ name: "pi", compactions: pi.compactions }`.
- Test B runs over the fixture's own streams, not an empty session as DESIGN.md:189 says. Over an
empty session every fixture with a cursor would go store-incomplete (the stream went away), so
"the others go known" could not hold. The expected answers: known stays known (1), store-unreadable
goes known with 0 (the cursor is at the end), not-collected goes known with 1, and
store-incomplete, torn and both harness-unsupported fixtures stay unknown.
- The malformed-answer test (17 answers, plus a module that throws, over a fresh store and over the
known fixture) and the argument test (exact keys; core passes a copy of the cursors).
- `packages/plugins/tests/harness-compactions.test.mjs`: CASES plain, budget (100 bytes), cap (1
stream of 2), shrank and header against pi; pi's `first` with and without Claude history;
claude-code; and a module re-exporting every pi export but `compactions`, refused `missing-export`.
- capture.mjs reads both capture blocks from the test files. It replays each fixture with base's
`collectCompactions(store, "<name>", ...)` and compares the ledger byte for byte, and it runs base's
`scanPi` per case and compares the answer under base's reason order (:301 at base) and
`PI_VERSION`. It prints base's `compactionCount` per fixture: that is the before half.
## Expected red on 1f6db1589
- seat compactions-ledger: the seven test A pass (the ledger rules are base's). Test B fails for known,
store-unreadable and not-collected: base takes the object as a harness that isn't pi and writes
harness-unsupported. Test B passes on base for the other four, whose expected answer is unknown with
the same reason. The malformed-answer and argument tests fail: base never calls the module.
Correction after the run: test B fails on base for claude-history too, so the reds are known,
store-unreadable, not-collected and claude-history. On base the object harness takes :304, which
writes `collected` without a scan, so the count stays null. Pi on the tip scans on a later
collection, finds c1 and answers 1, still unknown with harness-unsupported. The test is right and
this list was wrong (Duckworth agreed, duckworth-0287).
- plugins harness-compactions: the five CASES, the `first` test and the claude-code test fail with a
TypeError (`compactions` isn't exported on base). The missing-export test fails: base's loader loads
the module.
- harness.test.mjs:192 is already red on base. That's the brief's starting point.
## Run plan (mos-1275, `timeout -k 10`)
All on a CLEAR from Sage. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Gate worktrees
under /mnt/storage/scratch, node_modules linked right after `git worktree add`, pipefail and tee.
| Run | Longest recorded | LIMIT |
| --- | --- | --- |
| capture.mjs on a 1f6db1589 worktree (base and candidate both it) | none | 120 s |
| red first: the two new files on 1f6db1589, each whole | none (seat suite 7 s) | 300 s |
| the touched test files whole on 44104adb1, one at a time | compactions-launch alone: none; other files 15 s | 600 s |
| bus suite | 25 s (270 r5) | 900 s |
| cli suite | 682 s (270 r5) | 2100 s |
| plugins suite | none recorded | 900 s |
| webui suite | 58.2 s (294) | 600 s |
| business suite | none recorded | 900 s |
| seat suite whole, `node --test tests/` (Duckworth C1) | 7 s (270 r5) | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant, its kill files whole | 1 s (264a probes) | 300 s |
- In the cli run, compactions-launch's T3 tests (base :206, and :305 with the :344 edit; :207, :306
and :345 after the import line) must show as run, not skipped on `sandboxProblem()` (base :36, now
:37). The report names every skip (Duckworth C2).
- Plugins must have zero failures.
- A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos
unplaced. A first-run red in a new 307 test is placed and reported after (mos-1272). A red in an old
test goes to Sage, unfixed.
## Mutants
Each on an isolated copy of the file, restore checked by sha256, only on a green base.
| # | Change | Kill files |
| --- | --- | --- |
| M1 | `readLedger`: `if (r.harness !== "pi" \|\| r.reason === "harness-unsupported")` restored | plugins harness.test.mjs (:192) |
| M2 | pi `compactions` drops the `first && claudeHistory(store)` branch | seat compactions-budget.test.mjs (base :86, now :87), plugins harness-compactions.test.mjs |
| M3 | `readLedger` never sets `unsupported` | seat compactions-ledger.test.mjs, seat compactions.test.mjs (base :197, now :199) |
| M4 | `collectCompactions` skips `checkAnswer` | seat compactions-ledger.test.mjs |
## Runs
Sage's CLEARs: sage-3455 for steps 1-3, sage-3465 for steps 4-6. Gate worktrees at
/mnt/storage/scratch/g307-base (1f6db1589), g307-tip and g307-mut (1f00d4f91), with node_modules
linked. TMPDIR was /mnt/storage/scratch/tmp/gTIK. Outputs went to /mnt/storage/scratch/gizmo-307-out/s1 and s2.
### Steps 1-3 (13:31:21Z-13:31:36Z, then the capture rerun at 13:32Z)
- capture.mjs crashed with ENOENT on its first run. Base `sessionStore` (session.mjs:78
`privateDirs`, called from :117) makes its directories one level at a time, and :44 passed a
per-fixture root that didn't exist yet. The fix is one line before :44,
`` mkdirSync(join(root, `f-${name}`)) ``. A copy with that line, run on g307-base as both base and
candidate, exited rc 0 and printed `all match`: 7 ledgers byte for byte, 7 base counts and 5 scans.
Base counts: known 1 known; store-incomplete, store-unreadable and not-collected null, unknown
with their own reason; claude-code and claude-history null, harness-unsupported; torn 1,
store-incomplete.
- Red first on 1f6db1589, both rc 1. Seat compactions-ledger: 9 pass, 7 fail (the corrected list
above, plus the malformed-answer and argument tests). Plugins harness-compactions: 0 pass, 8 fail.
Seven fail with `TypeError: pi.compactions is not a function` or `claude.compactions is not a
function`. The missing-export test fails because base loads the module.
- Touched files whole on 1f00d4f91, all rc 0: compactions-ledger 16/16, harness-compactions 8/8,
kinds 5/5, harness 9/9 (:192 green), compactions 20/20, compactions-budget 10/10,
compactions-launch 5/5 with 0 skipped.
### Steps 4-6 (14:00:10Z-14:14:00Z, on 1f00d4f91)
| Run | rc | Time | Result |
| --- | --- | --- | --- |
| bus | 0 | 24 s | 890 pass, 0 fail, 0 skipped |
| cli | 0 | 631 s | 927 pass, 0 fail, 0 skipped; T3 :207, :306, :345 ran |
| plugins | 0 | 0 s | 119 pass, 0 fail, 0 skipped |
| webui | 0 | 57 s | 374 pass, 0 fail, 1 skipped (below) |
| business | 0 | 2 s | 231 pass, 0 fail, 0 skipped |
| seat | 0 | 7 s | 218 pass, 0 fail, 0 skipped |
| test-auth | 0 | 0 s | 15 passed, 0 failed |
| test-conductor | 0 | 7 s | |
| test-config | 0 | 3 s | |
| test-discord | 0 | 7 s | |
| test-extension-package | 0 | 2 s | |
| test-foundation | 0 | 39 s | |
| test-land-work | 0 | 12 s | |
| test-queue | 0 | 20 s | one skip (below) |
| test-release | 0 | 3 s | 15 passed, 0 failed |
| test-task | 0 | 14 s | 92 passed, 0 failed, two skips (below) |
Skips:
- webui: "rebuilding from the source gives the same bytes" needs the pinned esbuild. 307 doesn't
touch the editor.
- test-queue: "queue verify and render --check", because the gate worktree isn't the queue's
canonical root.
- test-task: "live provider cases" twice (row 244, no provider call in the gate).
Mutants ran in g307-mut. Each restore matched its sha256, and the tree was clean after M4.
| # | Killed by |
| --- | --- |
| M1 | plugins harness.test.mjs:192 |
| M2 | seat compactions-budget.test.mjs:87 and :104; plugins harness-compactions.test.mjs:47 |
| M3 | seat compactions-ledger.test.mjs:123 (test B, claude-code and claude-history); seat compactions.test.mjs:199 |
| M4 | seat compactions-ledger.test.mjs:151 (the malformed-answer test) |
There were no reds, so nothing was placed under mos-1272. The sweep found no node or chromium left in
my cwds.
+241
View File
@@ -0,0 +1,241 @@
# Row 307 (#1814, Vikunja 598): row 270's pi compaction reading moves behind the harness kind
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
`docs/plans/2026-10-23_harness-kind-compactions.md` at 1e629c892, which is the base. Mos ruled the
move in mos-1455 item 3 and declined an EXCEPT entry.
## What is red, as the code has it at 1e629c892
`packages/plugins/tests/harness.test.mjs:192` greps core sources for a quoted harness name. It finds
three lines in `packages/seat/src/compactions.mjs`:
- `:232` in `readLedger`: `if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;`
- `:283` and `:287` in `collectCompactions`: the `harness === "pi"` branches.
Everything else row 270 put in that file is either harness-free (the ledger, the budget and stream
settings, `storeDir`, `compactionCount`) or pi-only (`scanPi`, `PI_VERSION`, `claudeHistory`, and
the helpers only `scanPi` uses: `readRange`, `digest`, `STREAM`, `TAIL`).
## The new export: `compactions`
`packages/plugins/src/kinds.mjs:54` appends `"compactions"` to the harness kind's exports, so the
loader refuses a module without it (`loader.mjs:61-66`, code `missing-export`).
```
compactions({ store, cursors, budget, streams, first })
-> { found, cursors, known, reason, version }
```
Arguments, all from core:
- `store` is `{ session, projects }`, the two native store directories `sessionStore` makes. The
module never gets `store.dir`, the directory that holds the ledger.
- `cursors` is a copy of the ledger's cursor map, `{ [stream]: { bytes, tail } }`.
- `budget` and `streams` are `readBudget(...)` and `readStreams(...)`, as the launcher passes them
now (`launcher.mjs:299`, `:305`). Core keeps both readers and their defaults.
- `first` is true when the store has no ledger yet (today's `!prior`).
The answer:
- `found`: `[{ entry, completedAt, stream }]`, the compactions read past the cursors. `entry` is a
non-empty string, `completedAt` a string or null, `stream` a non-empty string.
- `cursors`: `{ [stream]: { bytes, tail } }` for each stream the module read. `bytes` is a safe
integer of 0 or more and `tail` 64 lowercase hex.
- `reason`: null, `store-incomplete`, `store-unreadable`, `not-collected` or `harness-unsupported`.
- `known`: a boolean, true exactly when `reason` is null.
- `version`: the store format the module read against as a string, or null when it read nothing.
Core checks the answer before it writes. Anything else throws `harness <name> compactions: bad
answer: <what>` and nothing is appended. A throw from the module itself is the same case. Both reach
the launcher's existing catch (`launcher.mjs:313-316`), which logs the message and records the run's
`compactions` event as unknown with `store-unreadable`, as a scan that throws today does.
### interfaceVersion stays 1
- Every harness module lives in this tree, and the loader loads nothing else (decision 143,
`loader.mjs:1-5`). Both shipped modules gain the export in the same commit.
- A module without the export already refuses at load, as `missing-export` naming `compactions`.
That is the brief's third test. A bump would add a second refusal for the same fact.
- No existing export changes its arguments or answer. Row 125 moved limit-check to 2 because
`mayLaunch`, `mustStop` and `report` changed shape. Nothing like that happens here.
- `harness.test.mjs:36` pins `interfaceVersion` 1, and the brief keeps that file unchanged.
The tradeoff: a module someone wrote against version 1 without `compactions` reports
`missing-export`, not `interface-version`. The message names the export, which is the more useful of
the two.
## What moves where
New file `packages/plugins/harness/pi/compactions.mjs`, imported by `pi/index.mjs`. The loader
checks only `index.mjs` itself (`loader.mjs:42-54`), and claude-code's module already imports files
outside its directory (`claude-code/index.mjs:11-12`). It takes, unchanged in behaviour:
- `scanPi`, with `readRange`, `digest`, `STREAM`, `TAIL` and the chunk size it reads by;
- `PI_VERSION` ("0.85.1");
- `claudeHistory(store)`;
- the reason order `["store-incomplete", "store-unreadable", "not-collected"]` from `:301`;
- the header comment's Pi paragraphs (`compactions.mjs:12-19`, `:21-24`).
pi's `compactions`:
- `first` and `claudeHistory(store)`: answers `{ found: [], cursors: {}, known: false, reason:
"harness-unsupported", version: "0.85.1" }`. That is today's `:283-286`.
- otherwise: `scanPi(store.session, cursors, budget, streams)`, and answers its `found` and
`cursors` with the first reason in the order above, `known` when that's null, and version
"0.85.1". That is today's `:287-302` before the dedupe.
claude-code's `compactions` reads nothing and answers `{ found: [], cursors: {}, known: false,
reason: "harness-unsupported", version: null }`. That is today's `:303-304`. Vikunja 466 still has
no pinned Claude Code format.
## Core keeps the ledger
`packages/seat/src/compactions.mjs` keeps `LEDGER`, `READ_BYTES`, `STREAMS`, `readBudget`,
`readStreams`, `storeDir`, `readLedger`, `compactionCount`, the key and the single O_APPEND write.
`collectCompactions(store, harness, { now, budget, streams })` takes `harness = { name,
compactions }`, the name the route used and the loaded module's export. It:
1. reads the ledger (`prior`), as now;
2. calls `harness.compactions({ store: { session: store.session, projects: store.projects },
cursors: { ...ledger.cursors }, budget, streams, first: prior === null })` and checks the answer;
3. appends, in today's order: one `compaction` line per key the ledger doesn't hold yet, one
`cursor` line per stream whose cursor moved, then `{ v: 1, kind: "collected", at, known, reason,
harness: name, version }`. These are the same lines and field order as today's `:288-302` and
`:304`. `name` is a variable, never a literal;
4. returns `compactionCount(store.dir)`.
`readLedger`'s unsupported test becomes `r.reason === "harness-unsupported"`, a field every answer
carries. No core line names a harness. The core comments drop the quoted names too, because the
grep reads comments.
`packages/cli/src/launcher.mjs:309-318`: the `compactions` closure takes the loaded module too and
calls `collectCompactions(store, { name: model.harness, compactions: harness.compactions }, {
budget, streams })`. Both call sites, `:509` and `:650`, have `harness` in scope (it's
`startLocked`'s `let harness` from `:503`; the `exited.then` callback at `:632` closes over it).
The closure calls the export directly, not through `call()` (`launcher.mjs:196`), because `call()`
turns a throw into a run refusal. The closure gets a comment saying so (Duckworth N1).
`bus/src/runtime.mjs:282` and `broker.mjs:885-889` keep calling `compactionCount` and don't change.
`packages/plugins/README.md`, Harness section, and the comment at `kinds.mjs:35-51` each gain the
`compactions` line with the contract above.
## A ledger row 270 wrote reads the same count
Row 270's code writes a `collected` line in exactly three places:
| Line | harness | reason | version | old rule (`:232`) | new rule |
| --- | --- | --- | --- | --- | --- |
| `:286`, Claude history on the first pi collection | `"pi"` | `harness-unsupported` | "0.85.1" | unsupported | unsupported |
| `:302`, a pi scan | `"pi"` | null, `store-incomplete`, `store-unreadable` or `not-collected` | "0.85.1" | supported | supported |
| `:304`, any other harness name (`claude-code`, or `claude` in the tests) | that name | `harness-unsupported` | null | unsupported | unsupported |
The two rules agree on every line row 270 can write. The rest of `readLedger` (the `bad`, `torn`,
`incomplete` and `events` handling) and all of `compactionCount` don't change. So every ledger row
270 wrote reads the same `{ count, known, reason, at }` after the move. Nothing rewrites a ledger;
the next collection only appends.
The rules would differ only on a `collected` line naming a harness other than `"pi"` with a reason
other than `harness-unsupported`. Row 270 never writes one, and the launcher is the ledger's only
writer (`compactions.mjs:7-10`).
New collections after the move append the same bytes as before for the same store state. pi's
answer is today's scan result, core keeps the dedupe and the line format, and `harness` in the line
is still the route's name.
## Existing tests that change (to Mos through Sage, mos-1272)
Grepped for `interfaceVersion`, the harness exports, `write("harness"`, `fakeHarnesses` and
`collectCompactions(` in `packages/*/tests/*` and `scripts/`.
Four existing files change:
1. `packages/plugins/tests/kinds.test.mjs:17-21`: the pinned harness export list gains
`"compactions"` at the end.
2. `packages/seat/tests/compactions.test.mjs:11` and `:21`: import the two shipped modules. `setup`'s
`collect(harness = "pi")` passes `{ name: harness, compactions: MODULES[harness].compactions }`,
with `pi` mapped to the pi module and `claude` to claude-code's. The 27 `f.collect()` calls and
the two named ones at `:199` and `:201` stay as written.
3. `packages/seat/tests/compactions-budget.test.mjs:13` and `:24-25`: the same change in its
`collect`. Its calls at `:67-69` and `:129-134` stay as written.
4. `packages/cli/tests/compactions-launch.test.mjs:344`: the one direct
`collectCompactions(legacy, "pi")` becomes `collectCompactions(legacy, { name: "pi",
compactions: pi.compactions })`, with an import of the pi module.
Fixtures that build a harness module and need no edit, because they re-export a real module and
inherit `compactions`:
- `packages/cli/tests/helpers.mjs:133-151` `fakeHarnesses`, used by `compactions-launch`,
`idle-stop-race`, `launcher`, `lifecycle`, `session-clear`, `session-launch`, `thread-launch` and
`thread-revoke`;
- `packages/cli/tests/launch-routes.test.mjs:17-23`: `down`, `sparse`, `metered` and `throws`
re-export pi. `broken` lacks every export but `adapter` on purpose. The loader still refuses it at
`levels` first, so `:114` and `:138` (`missing-export`) hold;
- `packages/cli/tests/launcher.test.mjs:694-697`: `down` and `fixture` re-export pi.
`packages/plugins/tests/harness.test.mjs` doesn't change. `:33-37` loops over the kind's exports,
which both modules then have, and `:192` goes green.
No `scripts/test-*.sh` builds a harness module. `test-config.sh:266-280` checks only the two
config keys.
## New tests, red first (mos-1272)
1. `packages/seat/tests/compactions-ledger.test.mjs`: one fixture ledger per reason row 270 writes:
known, `store-incomplete`, `store-unreadable`, `not-collected`, `harness-unsupported` from
claude-code, `harness-unsupported` from Claude history, and a torn ledger.
- Each is literal JSONL in the test. Before the build, in the slot, a capture script runs base
1e629c892's `collectCompactions` with a fixed `now` over the same stores. The literals must
equal its output byte for byte. That proves row 270's code wrote them.
- `compactionCount` reads each one. I run this half on the base and on the candidate, and record
both outputs in BUILD.md as the before and after.
- Then one more collection through `{ name, compactions }` over an empty session gives each
fixture's expected next answer: unsupported and `store-incomplete` stay, the others go known.
This half is red on the base, where core treats the object as a harness that isn't pi.
2. `packages/plugins/tests/harness-compactions.test.mjs`:
- pi's `compactions` over fixture streams answers what base `scanPi` answers, captured by the same
script: a plain count, a budget that stops short (cursor at the last whole line,
`not-collected`), more streams than the cap (no cursor for unopened streams, `not-collected`),
a stream that shrank under its cursor (`store-incomplete`), a non-session header
(`store-unreadable`). Each answer has version "0.85.1".
- pi with `first` and a non-empty `projects` answers `harness-unsupported` without scanning.
Without `first`, the same store scans.
- claude-code answers `harness-unsupported` with version null, over directories that don't exist.
- A harness module that re-exports every other pi export by name, without `compactions`, refuses
at load: `missing-export`, "doesn't export compactions".
3. In the same seat file: core refuses each malformed answer and appends nothing. The cases are
`found` not an array, an empty `entry`, a cursor whose `bytes` is negative or `tail` isn't 64
hex, `known` true with a reason, an unknown reason, a numeric `version`, and a module that
throws. The module gets exactly `{ store: { session, projects }, cursors, budget, streams,
first }`.
## Mutants
- M1, the brief's first: core branches on a harness name again (`readLedger`'s `r.harness !== "pi"
||` restored). Killed by `harness.test.mjs:192`.
- M2, the brief's second: pi's module drops the Claude-history check. Killed by
`compactions-budget.test.mjs:86` and new test 2's `first` case.
- M3, the brief's third: `readLedger` never sets `unsupported`. Killed by new test 1's two
`harness-unsupported` fixtures and `compactions.test.mjs:197`.
- M4, mine: core skips the answer check. Killed by new test 3.
## Gate
Duckworth approves this design, then the manifest. On Sage's CLEAR:
- the capture script on base 1e629c892 first, then the new tests red on the base;
- the bus, cli, plugins (zero failures), webui and business suites, and every `scripts/test-*.sh`;
- the seat suite whole (`packages/seat`, `node --test tests/`), where two old-test edits, new
tests 1 and 3, and the M2 and M3 kills live (Duckworth C1);
- in the cli run, `compactions-launch`'s T3 tests (`:206`, and `:305` with the `:344` edit) must
run, not skip on `sandboxProblem()` (`:36`). BUILD.md names every skip (Duckworth C2);
- the mutants, each on an isolated copy with its restore checked by sha256.
Each suite runs under `timeout -k 10` at 3x or more of its longest recorded wall (mos-1275). I'll
put the numbers in BUILD.md before the first run. TMPDIR comes from
`mktemp -d /mnt/storage/scratch/tmp/gXXX`.
## Round 1
Duckworth approved round 1 on 67daf6403 (duckworth-0280). This revision takes C1 and C2 into
the gate, adds the N1 comment to the launcher plan, and corrects the N2 line refs (`:302`, `:301`).
@@ -0,0 +1,79 @@
// Row 307 (#1814): checks the new tests' literals against row 270's code at base 1e629c892.
//
// node capture.mjs <base checkout at 1e629c892> <candidate checkout>
//
// Reads the FIXTURES and CASES blocks (between `// capture:begin` and `// capture:end`) from the
// candidate's two new test files. Replays each fixture's steps with the base's collectCompactions,
// harness named by string as row 270 takes it, with the clock at 12:00:00 and one second a step,
// and compares the ledger byte for byte. Prints the base's compactionCount for each. Runs the
// base's scanPi over each case and compares the answer the base's reason order gives (:301) with
// version PI_VERSION. Exit 0 only when every one matches. Writes only under TMPDIR.
import assert from "node:assert/strict";
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const [base, cand] = process.argv.slice(2).map((p) => p && resolve(p));
if (!base || !cand) {
console.error("usage: node capture.mjs <base checkout> <candidate checkout>");
process.exit(2);
}
function block(file) {
const text = readFileSync(join(cand, file), "utf8");
const m = /\n\/\/ capture:begin\nconst [A-Z]+ = (\{[\s\S]*?\});\n\/\/ capture:end\n/.exec(text);
if (!m) throw new Error(`${file}: no capture block`);
return JSON.parse(m[1]);
}
const FIXTURES = block("packages/seat/tests/compactions-ledger.test.mjs");
const CASES = block("packages/plugins/tests/harness-compactions.test.mjs");
const seat = await import(pathToFileURL(join(base, "packages/seat/src/compactions.mjs")).href);
const { sessionStore } = await import(pathToFileURL(join(base, "packages/seat/src/session.mjs")).href);
const root = mkdtempSync(join(tmpdir(), "g307-capture-"));
let failed = 0;
const report = (what, same, detail) => {
console.log(`${same ? "MATCH" : "DIFF "} ${what}${detail ? ` ${detail}` : ""}`);
if (!same) failed++;
};
try {
for (const [name, fx] of Object.entries(FIXTURES)) {
mkdirSync(join(root, `f-${name}`));
const store = sessionStore(join(root, `f-${name}`), "acme", "th1", "s1");
for (const [file, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, file), text);
for (const [file, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, file), text);
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
for (const step of fx.steps) {
if (step.tear !== undefined) appendFileSync(join(store.dir, seat.LEDGER), step.tear);
else seat.collectCompactions(store, step.collect, { now, ...(step.budget === undefined ? {} : { budget: step.budget }) });
}
const got = readFileSync(join(store.dir, seat.LEDGER), "utf8");
const want = fx.ledger.join("\n") + "\n";
report(`ledger ${name}`, got === want, got === want ? `(${Buffer.byteLength(got)} bytes)` : `\n--- base wrote\n${got}--- test has\n${want}`);
const count = seat.compactionCount(store.dir);
report(`base count ${name}`, JSON.stringify(count) === JSON.stringify(fx.count), JSON.stringify(count));
}
for (const [name, c] of Object.entries(CASES)) {
const dir = join(root, `c-${name}`);
mkdirSync(dir);
for (const [file, text] of Object.entries(c.streams)) writeFileSync(join(dir, file), text);
const scan = seat.scanPi(dir, c.cursors, c.budget, c.cap);
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
const answer = { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: seat.PI_VERSION };
let same = true;
try {
assert.deepStrictEqual(answer, c.answer);
} catch {
same = false;
}
report(`scan ${name}`, same, JSON.stringify(scan));
}
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log(failed ? `${failed} differ` : "all match");
process.exit(failed ? 1 : 0);
+7 -4
View File
@@ -306,10 +306,13 @@ export function createLauncher({
if (system?.compactionStreams !== undefined && streams !== system.compactionStreams) {
log(`system config: compactionStreams ${JSON.stringify(system.compactionStreams)} isn't a whole number from 32 to 65536; compaction collections open up to the default ${STREAMS} streams`);
}
const compactions = (store, harness, { instance, thread, session, run }) => {
// Row 307 (#1814): the route's harness module reads its own store through the kind's
// `compactions`. The call skips call(): call() turns a throw into a launch refusal, and a
// collection that fails must never stop or fail the run, so this catch records it instead.
const compactions = (store, name, harness, { instance, thread, session, run }) => {
let c;
try {
c = collectCompactions(store, harness, { budget, streams });
c = collectCompactions(store, { name, compactions: harness.compactions }, { budget, streams });
} catch (e) {
log(`run ${run}: compactions: ${e.message}`);
c = { count: null, known: false, reason: "store-unreadable" };
@@ -506,7 +509,7 @@ export function createLauncher({
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
if (store) compactions(store, model.harness, { instance, thread, session, run });
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
@@ -647,7 +650,7 @@ export function createLauncher({
} finally {
try { unregisterRuntime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt cleanup refused`); }
if (store) compactions(store, model.harness, { instance, thread, session, run });
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
try { lock?.release(); }
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
children.delete(run);
@@ -28,6 +28,7 @@ import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
@@ -341,7 +342,7 @@ test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, i
seeded.close();
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
assert.equal(collectCompactions(legacy, "pi").count, 1);
assert.equal(collectCompactions(legacy, { name: "pi", compactions: pi }).count, 1);
host = await start(t, prepared);
const [row] = host.call("session.list", { thread: L });
assert.equal(row.id, L);
+15
View File
@@ -153,6 +153,21 @@ export returns:
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
how a turn ends and where its transcripts land.
- `compactions({store, cursors, budget, streams, first})` reads a thread
session's native store for row 270's compaction count (row 307). Core keeps
the ledger (`packages/seat/src/compactions.mjs`) and passes `store` as
`{session, projects}`, the ledger's `cursors` by stream, the read `budget`
and stream cap `streams` from the system config, and `first` when the
store has no ledger yet. The answer is `{found, cursors, known, reason,
version}`: `found` lists `{entry, completedAt, stream}` past the cursors,
`cursors` maps each stream read to `{bytes, tail}` (`tail` is the sha256 of
up to 4096 bytes ending there), `reason` is null, `store-incomplete`,
`store-unreadable`, `not-collected` or `harness-unsupported`, `known` is
true exactly when `reason` is null, and `version` names the store format
read, or null. Core refuses any other answer and writes nothing. pi reads
its streams within the budget and the cap, and answers
`harness-unsupported` on a first collection that finds Claude history.
Claude Code answers `harness-unsupported` with version null (Vikunja 466).
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
`mounts` adds its three files read-only and `bundle` names them in the
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
@@ -128,3 +128,6 @@ export function env({ files, policy, session }) {
// A turn ends when claude exits; there is no marker.
export const turnEnd = () => "exit";
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
// Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection
// reads nothing and the count is unknown with harness-unsupported, never 0.
export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
+166
View File
@@ -0,0 +1,166 @@
// Row 307 (#1814, Vikunja 598): pi's half of row 270's compaction count (264c, #1775, T1 item 7
// D6), moved here from packages/seat/src/compactions.mjs. Core keeps the ledger and calls
// `compactions` through the harness kind (packages/plugins/README.md has the contract). This file
// reads only the session's native pi store and the Claude history beside it.
//
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
// written by SessionManager.appendCompaction after the summary exists. A start
// or an aborted compaction writes none. A `compactionSummary` message, a
// message inside a compaction's retainedTail and a branch_summary are not
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
// file of the same directory with their id and timestamp unchanged, so a
// compaction's key is (entry id, entry timestamp) over the whole store, and the
// stream it was first seen in is recorded beside it.
//
// A session whose first collection finds Claude history in claude-projects/
// from before the ledger existed is unknown with harness-unsupported (row 270
// r2, B3): those runs' compactions can't be counted (Vikunja 466).
import { createHash } from "node:crypto";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
import { join } from "node:path";
export const PI_VERSION = "0.85.1";
const TAIL = 4096;
const CHUNK = 1 << 20;
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
// store-incomplete outranks store-unreadable: lost entries don't come back.
// not-collected is last: the next collection reads on.
const REASONS = ["store-incomplete", "store-unreadable", "not-collected"];
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
// Only the short tail reads (at most TAIL bytes) use this.
function readRange(fd, from, to) {
const out = Buffer.alloc(to - from);
let off = 0;
while (off < out.length) {
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
if (n === 0) break;
off += n;
}
return out.subarray(0, off);
}
// One collection over a Pi session directory. `cursors` are the ledger's,
// by stream. Opens at most `streams` streams, in name order, and reads at
// most `budget` bytes past their cursors, over all of them.
// Returns the compactions found past each cursor, the new cursors, and the
// problems: store-unreadable (a file that can't be read or isn't a Pi
// session), store-incomplete (a stream that shrank, changed or went away, or
// a line that isn't JSON) and not-collected (the budget or the stream cap
// ran out first). A
// missing directory is empty.
function scanPi(dir, cursors, budget, streams) {
const found = [], next = {}, problems = new Set();
let left = budget;
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code !== "ENOENT") problems.add("store-unreadable");
names = [];
}
const present = new Set(names);
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
if (visit.length > streams) problems.add("not-collected");
for (const name of visit.slice(0, streams)) {
const path = join(dir, name);
let fd;
try {
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
} catch {
problems.add("store-unreadable");
continue;
}
try {
const size = fstatSync(fd).size;
const c = cursors[name];
let from = 0;
if (c) {
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
else problems.add("store-incomplete");
}
let header = from > 0;
const take = (raw) => {
const line = raw.toString("utf8");
if (!line.trim()) return;
let e;
try {
e = JSON.parse(line);
} catch {
e = null;
}
if (!e || typeof e !== "object" || Array.isArray(e)) {
problems.add("store-incomplete");
return;
}
if (!header) {
header = true;
if (e.type !== "session") problems.add("store-unreadable");
return;
}
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
}
};
// A CHUNK at a time, up to the budget left. Complete lines only: a line
// split across chunks waits in `carry`, and a partial last line waits
// for its newline. `end` is past the last whole line.
const stop = Math.min(size, from + left);
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
let pos = from, end = from, carry = [];
while (pos < stop) {
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
if (n === 0) break;
const view = chunk.subarray(0, n);
let i = 0;
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
carry = [];
i = nl + 1;
end = pos + i;
}
if (i < n) carry.push(Buffer.from(view.subarray(i)));
pos += n;
}
left -= pos - from;
if (stop < size) problems.add("not-collected");
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
} catch {
problems.add("store-unreadable");
} finally {
closeSync(fd);
}
}
return { found, cursors: next, problems: [...problems].sort() };
}
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
// directory that can't be read throws, so the collection is not recorded and
// the next one checks again.
function claudeHistory(store) {
if (!store.projects) return false;
try {
return readdirSync(store.projects).length > 0;
} catch (e) {
if (e.code === "ENOENT") return false;
throw e;
}
}
// The harness kind's `compactions` for pi: the compactions past the ledger's
// cursors, the moved cursors, and the reason the count isn't whole, if any.
export function compactions({ store, cursors, budget, streams, first }) {
if (first && claudeHistory(store)) {
// Claude runs before this ledger: their compactions can't be counted
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
return { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: PI_VERSION };
}
const scan = scanPi(store.session, cursors, budget, streams);
const reason = REASONS.find((p) => scan.problems.includes(p)) ?? null;
return { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: PI_VERSION };
}
+2
View File
@@ -73,3 +73,5 @@ export function env({ policy, tools, session }) {
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
export const turnEnd = () => "marker";
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
// Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs).
export { compactions } from "./compactions.mjs";
+3 -1
View File
@@ -49,10 +49,12 @@ export const KINDS = Object.freeze({
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
// compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version },
// the session store's compactions past the ledger's cursors (row 307)
harness: Object.freeze({
kind: "harness", interfaceVersion: 1, switch: "list",
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"]),
}),
});
@@ -0,0 +1,75 @@
// Row 307 (#1814, Vikunja 598): the harness kind's `compactions`. pi's answers what row 270's
// scanPi read at 1e629c892, with its reason order and version; claude-code reads nothing and
// answers harness-unsupported; a module without the export doesn't load.
//
// CASES: each case's streams and cursors, scanned at 1e629c892 with its budget and cap, give its
// answer (agents/gizmoduck/work/queue-307/capture.mjs checks that).
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { KINDS } from "../src/kinds.mjs";
import * as pi from "../harness/pi/index.mjs";
import * as claude from "../harness/claude-code/index.mjs";
import { tree } from "./helpers.mjs";
// capture:begin
const CASES = {
"plain": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": true, "reason": null, "version": "0.85.1"}},
"budget": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 100, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
"cap": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n", "b.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
"shrank": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "store-incomplete", "version": "0.85.1"}},
"header": {"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 29, "tail": "91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df"}}, "known": false, "reason": "store-unreadable", "version": "0.85.1"}}
};
// capture:end
function stores(t, streams = {}, projects = {}) {
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-compactions-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const store = { session: join(root, "session"), projects: join(root, "claude-projects") };
for (const [dir, files] of [[store.session, streams], [store.projects, projects]]) {
mkdirSync(dir);
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
}
return store;
}
for (const [name, c] of Object.entries(CASES)) {
test(`row 307 pi answers what row 270 read: ${name}`, (t) => {
const store = stores(t, c.streams);
assert.deepEqual(pi.compactions({ store, cursors: c.cursors, budget: c.budget, streams: c.cap, first: false }), c.answer);
});
}
test("row 307 pi's first collection over Claude history answers harness-unsupported without a scan, and a later one scans", (t) => {
const { streams, budget, cap, answer } = CASES.plain;
const store = stores(t, streams, { "p.jsonl": "{}\n" });
const args = { store, cursors: {}, budget, streams: cap };
assert.deepEqual(pi.compactions({ ...args, first: true }), { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: "0.85.1" });
assert.deepEqual(pi.compactions({ ...args, first: false }), answer);
// With no Claude history, the first collection scans too.
assert.deepEqual(pi.compactions({ ...args, store: stores(t, streams), first: true }), answer);
});
test("row 307 claude-code reads nothing and answers harness-unsupported with no version", () => {
const store = { session: "/nonexistent/session", projects: "/nonexistent/claude-projects" };
for (const first of [true, false]) {
assert.deepEqual(claude.compactions({ store, cursors: {}, budget: 1 << 20, streams: 32, first }),
{ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
}
});
test("row 307 a harness module without compactions doesn't load: missing-export", async (t) => {
const s = tree(t);
const url = pathToFileURL(resolve(fileURLToPath(new URL("../harness/pi/index.mjs", import.meta.url)))).href;
const names = ["interfaceVersion", ...KINDS.harness.exports.filter((k) => k !== "compactions")];
s.write("harness", "no-compactions", `export { ${names.join(", ")} } from ${JSON.stringify(url)};\n`);
const l = await s.loader();
const result = await l.load({ harness: ["no-compactions"] });
assert.deepEqual(result.refused.map((e) => [e.kind, e.name, e.code]), [["harness", "no-compactions", "missing-export"]]);
assert.match(result.refused[0].message, /^harness no-compactions doesn't export compactions$/);
assert.deepEqual(l.loaded().harness, []);
});
+1 -1
View File
@@ -17,7 +17,7 @@ test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1,
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
kind: "harness", interfaceVersion: 1, switch: "list",
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"],
});
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
+58 -171
View File
@@ -9,19 +9,14 @@
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
//
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
// written by SessionManager.appendCompaction after the summary exists. A start
// or an aborted compaction writes none. A `compactionSummary` message, a
// message inside a compaction's retainedTail and a branch_summary are not
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
// file of the same directory with their id and timestamp unchanged, so a
// compaction's key is (entry id, entry timestamp) over the whole store, and the
// stream it was first seen in is recorded beside it.
//
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
// any Claude run collected for is unknown with harness-unsupported, never 0.
// So is one whose first collection finds Claude history in claude-projects/
// from before this ledger existed (row 270 r2, B3).
// What a compaction is depends on the harness, so core never reads a native
// stream itself (row 307, #1814, Vikunja 598). Each collection calls the route's
// harness module's `compactions` (packages/plugins/README.md has the contract),
// checks its answer, and writes the ledger lines from it. A compaction's key is
// (entry, completedAt) over the whole store, and the stream it was first seen in
// is recorded beside it. A harness with no pinned compaction format answers
// harness-unsupported, and so does one that can't count the store's history: a
// session any such collection ran for is unknown for good, never 0.
//
// One collection reads at most a budget of stream bytes (row 270 r2, B1).
// The budget is the system config's compactionReadBytes, which only a human
@@ -46,14 +41,11 @@
// that shrank, changed under its cursor or went away is store-incomplete from
// then on: entries may be lost, so the count stays a lower bound for good.
import { createHash } from "node:crypto";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
import { join } from "node:path";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export const PI_VERSION = "0.85.1";
export const LEDGER = "compactions.jsonl";
const TAIL = 4096;
const CHUNK = 1 << 20;
// BUILD.md (queue-270, round 2) has the measured stream sizes behind the default.
export const READ_BYTES = 64 * CHUNK;
@@ -61,9 +53,7 @@ const READ_MIN = CHUNK, READ_MAX = 1024 * CHUNK;
// BUILD.md (queue-270, round 3) has the measured stream counts behind the default.
export const STREAMS = 1024;
const STREAMS_MIN = 32, STREAMS_MAX = 65536;
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
@@ -87,115 +77,6 @@ export function readStreams(value) {
return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;
}
// Only the short tail reads (at most TAIL bytes) use this.
function readRange(fd, from, to) {
const out = Buffer.alloc(to - from);
let off = 0;
while (off < out.length) {
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
if (n === 0) break;
off += n;
}
return out.subarray(0, off);
}
// One collection over a Pi session directory. `cursors` are the ledger's,
// by stream. Opens at most `streams` streams, in name order, and reads at
// most `budget` bytes past their cursors, over all of them.
// Returns the compactions found past each cursor, the new cursors, and the
// problems: store-unreadable (a file that can't be read or isn't a Pi
// session), store-incomplete (a stream that shrank, changed or went away, or
// a line that isn't JSON) and not-collected (the budget or the stream cap
// ran out first). A
// missing directory is empty.
export function scanPi(dir, cursors = {}, budget = READ_BYTES, streams = STREAMS) {
const found = [], next = {}, problems = new Set();
let left = budget;
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code !== "ENOENT") problems.add("store-unreadable");
names = [];
}
const present = new Set(names);
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
if (visit.length > streams) problems.add("not-collected");
for (const name of visit.slice(0, streams)) {
const path = join(dir, name);
let fd;
try {
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
} catch {
problems.add("store-unreadable");
continue;
}
try {
const size = fstatSync(fd).size;
const c = cursors[name];
let from = 0;
if (c) {
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
else problems.add("store-incomplete");
}
let header = from > 0;
const take = (raw) => {
const line = raw.toString("utf8");
if (!line.trim()) return;
let e;
try {
e = JSON.parse(line);
} catch {
e = null;
}
if (!e || typeof e !== "object" || Array.isArray(e)) {
problems.add("store-incomplete");
return;
}
if (!header) {
header = true;
if (e.type !== "session") problems.add("store-unreadable");
return;
}
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
}
};
// A CHUNK at a time, up to the budget left. Complete lines only: a line
// split across chunks waits in `carry`, and a partial last line waits
// for its newline. `end` is past the last whole line.
const stop = Math.min(size, from + left);
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
let pos = from, end = from, carry = [];
while (pos < stop) {
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
if (n === 0) break;
const view = chunk.subarray(0, n);
let i = 0;
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
carry = [];
i = nl + 1;
end = pos + i;
}
if (i < n) carry.push(Buffer.from(view.subarray(i)));
pos += n;
}
left -= pos - from;
if (stop < size) problems.add("not-collected");
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
} catch {
problems.add("store-unreadable");
} finally {
closeSync(fd);
}
}
return { found, cursors: next, problems: [...problems].sort() };
}
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
// by any line that isn't a v 1 record, a torn last line included.
export function readLedger(dir) {
@@ -229,28 +110,16 @@ export function readLedger(dir) {
else if (r.kind === "collected") {
last = r;
if (r.reason === "store-incomplete") incomplete = true;
if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;
if (r.reason === "harness-unsupported") unsupported = true;
} else bad = true;
}
return { events, cursors, last, incomplete, unsupported, bad, torn };
}
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
// directory that can't be read throws, so the collection is not recorded and
// the next one checks again.
function claudeHistory(store) {
if (!store.projects) return false;
try {
return readdirSync(store.projects).length > 0;
} catch (e) {
if (e.code === "ENOENT") return false;
throw e;
}
}
// What session.list shows: { count, known, reason, at }. Known only when the
// ledger is whole, every collection ran on Pi and none lost entries. Any other
// answer is unknown with a reason and a lower bound, or null for none: never 0.
// ledger is whole, no collection was harness-unsupported and none lost
// entries. Any other answer is unknown with a reason and a lower bound, or null
// for none: never 0.
export function compactionCount(dir) {
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
let ledger;
@@ -270,39 +139,57 @@ export function compactionCount(dir) {
return { count, known: true, reason: null, at };
}
// One collection for a session's store ({ dir, session } from sessionStore),
// at run start (resume) and run end, under the session's workspace lock.
// Appends the new compactions, the moved cursors and one collected line in a
// single O_APPEND write, then answers the count. A key already in the ledger
// is never written again, and nothing rewrites the file.
const REASONS = ["store-incomplete", "store-unreadable", "not-collected", "harness-unsupported"];
const HEX = /^[0-9a-f]{64}$/;
// The harness module's answer, checked before anything is written: what the
// export's contract allows and nothing else. Returns what's wrong, or null.
function checkAnswer(a) {
const plain = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
if (!plain(a)) return "not an object";
if (!Array.isArray(a.found)) return "found isn't a list";
for (const f of a.found) {
if (!plain(f) || typeof f.entry !== "string" || !f.entry) return "a found entry has no entry id";
if (f.completedAt !== null && typeof f.completedAt !== "string") return `found ${f.entry} has a completedAt that isn't a string or null`;
if (typeof f.stream !== "string" || !f.stream) return `found ${f.entry} has no stream`;
}
if (!plain(a.cursors)) return "cursors isn't an object";
for (const [stream, c] of Object.entries(a.cursors)) {
if (!plain(c) || !Number.isSafeInteger(c.bytes) || c.bytes < 0 || typeof c.tail !== "string" || !HEX.test(c.tail)) return `cursor ${JSON.stringify(stream)} isn't { bytes, tail }`;
}
if (a.reason !== null && !REASONS.includes(a.reason)) return `reason ${JSON.stringify(a.reason)} isn't one of ${REASONS.join(", ")}`;
if (a.known !== (a.reason === null)) return "known must be true exactly when reason is null";
if (a.version !== null && typeof a.version !== "string") return "version isn't a string or null";
return null;
}
// One collection for a session's store ({ dir, session, projects } from
// sessionStore), at run start (resume) and run end, under the session's
// workspace lock. `harness` is { name, compactions }: the route's harness name
// and its loaded module's export. Appends the new compactions, the moved
// cursors and one collected line in a single O_APPEND write, then answers the
// count. A key already in the ledger is never written again, and nothing
// rewrites the file. A module that throws or answers out of contract throws
// here, before anything is written.
export function collectCompactions(store, harness, { now = () => new Date().toISOString(), budget = READ_BYTES, streams = STREAMS } = {}) {
const prior = readLedger(store.dir);
const ledger = prior ?? { events: new Map(), cursors: {}, torn: false };
const at = now();
const answer = harness.compactions({ store: { session: store.session, projects: store.projects }, cursors: { ...ledger.cursors }, budget, streams, first: prior === null });
const wrong = checkAnswer(answer);
if (wrong) throw new Error(`harness ${harness.name} compactions: bad answer: ${wrong}`);
const out = [];
if (harness === "pi" && !prior && claudeHistory(store)) {
// Claude runs before this ledger: their compactions can't be counted
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: PI_VERSION });
} else if (harness === "pi") {
const scan = scanPi(store.session, ledger.cursors, budget, streams);
for (const f of scan.found) {
const key = keyOf(f.entry, f.completedAt);
if (ledger.events.has(key)) continue;
ledger.events.set(key, f);
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
}
for (const [stream, c] of Object.entries(scan.cursors)) {
const old = ledger.cursors[stream];
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
}
// store-incomplete outranks store-unreadable: lost entries don't come back.
// not-collected is last: the next collection reads on.
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
} else {
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
for (const f of answer.found) {
const key = keyOf(f.entry, f.completedAt);
if (ledger.events.has(key)) continue;
ledger.events.set(key, f);
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
}
for (const [stream, c] of Object.entries(answer.cursors)) {
const old = ledger.cursors[stream];
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
}
out.push({ v: 1, kind: "collected", at, known: answer.known, reason: answer.reason, harness: harness.name, version: answer.version });
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
try {
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectCompactions, LEDGER, READ_BYTES, readBudget, readStreams, STREAMS } from "../src/compactions.mjs";
import { sessionStore } from "../src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
const MiB = 1 << 20;
@@ -22,7 +23,7 @@ function setup(t) {
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
const collect = (harness = "pi", budget = undefined, streams = undefined) =>
collectCompactions(store, harness, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
collectCompactions(store, { name: harness, compactions: { pi }[harness] }, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
const records = () => readFileSync(join(store.dir, LEDGER), "utf8").trim().split("\n").map((l) => JSON.parse(l));
return { root, store, collect, records };
}
@@ -0,0 +1,184 @@
// Row 307 (#1814, Vikunja 598): core keeps the ledger and asks the harness module for the
// compactions. A ledger row 270 wrote reads the same count after the move, the next collection
// goes through the module's `compactions`, and core refuses an answer outside the contract before
// it writes anything.
//
// FIXTURES are what row 270's collectCompactions wrote at 1e629c892, one ledger per reason it
// writes plus a torn one: each fixture's streams, Claude projects and steps, replayed there with
// the clock at 12:00:00 and one second a step, give its ledger byte for byte
// (agents/gizmoduck/work/queue-307/capture.mjs checks that). `count` is compactionCount over it,
// and `next` is the count after one more pi collection at the next second.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectCompactions, compactionCount, LEDGER } from "../src/compactions.mjs";
import { sessionStore } from "../src/session.mjs";
import * as pi from "../../plugins/harness/pi/index.mjs";
// capture:begin
const FIXTURES = {
"known": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"store-incomplete": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\nnot json\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":99,\"tail\":\"14607eb2c2f70f460e1a420da1b4ead7f00f2a81cc4105e9e55e6c5c828d6626\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-incomplete\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"}
},
"store-unreadable": {
"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":29,\"tail\":\"91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-unreadable\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "store-unreadable", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 0, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"not-collected": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi", "budget": 100}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":90,\"tail\":\"2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"not-collected\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "not-collected", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"claude-code": {
"streams": {},
"steps": [{"collect": "claude-code"}],
"ledger": [
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"claude-code\",\"version\":null}"
],
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
},
"claude-history": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"projects": {"p.jsonl": "{}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
},
"torn": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi"}, {"tear": "{\"v\":1,\"kind\":\"coll"}, {"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}",
"{\"v\":1,\"kind\":\"coll",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:01.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"},
"next": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:02.000Z"}
}
};
// capture:end
function setup(t, fx = { streams: {}, ledger: null }) {
const root = mkdtempSync(join(tmpdir(), "seat-compactions-ledger-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const store = sessionStore(root, "acme", "th1", "s1");
for (const [name, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, name), text);
for (const [name, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, name), text);
if (fx.ledger) writeFileSync(join(store.dir, LEDGER), fx.ledger.join("\n") + "\n", { mode: 0o600 });
const ledger = () => {
try {
return readFileSync(join(store.dir, LEDGER), "utf8");
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
};
return { root, store, ledger };
}
for (const [name, fx] of Object.entries(FIXTURES)) {
test(`row 307 the ${name} ledger row 270 wrote reads the same count`, (t) => {
const f = setup(t, fx);
assert.deepEqual(compactionCount(f.store.dir), fx.count);
});
test(`row 307 after the ${name} ledger, one collection through pi's module answers the next count`, (t) => {
const f = setup(t, fx);
assert.deepEqual(collectCompactions(f.store, { name: "pi", compactions: pi.compactions }, { now: () => fx.next.at }), fx.next);
});
}
const ok = { found: [], cursors: {}, known: true, reason: null, version: null };
const HEX = "0".repeat(64);
const BAD = [
[null, "not an object"],
[[], "not an object"],
[{ ...ok, found: {} }, "found isn't a list"],
[{ ...ok, found: [{ entry: "", completedAt: null, stream: "a.jsonl" }] }, "a found entry has no entry id"],
[{ ...ok, found: ["c1"] }, "a found entry has no entry id"],
[{ ...ok, found: [{ entry: "c1", completedAt: 5, stream: "a.jsonl" }] }, "found c1 has a completedAt that isn't a string or null"],
[{ ...ok, found: [{ entry: "c1", completedAt: null, stream: "" }] }, "found c1 has no stream"],
[{ ...ok, cursors: [] }, "cursors isn't an object"],
[{ ...ok, cursors: { "a.jsonl": { bytes: -1, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1.5, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: HEX.toUpperCase().replace(/0/g, "A") } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: "0".repeat(63) } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, reason: "lost", known: false }, 'reason "lost" isn\'t one of store-incomplete, store-unreadable, not-collected, harness-unsupported'],
[{ ...ok, reason: "not-collected" }, "known must be true exactly when reason is null"],
[{ ...ok, known: false }, "known must be true exactly when reason is null"],
[{ ...ok, known: 1 }, "known must be true exactly when reason is null"],
[{ ...ok, version: 1 }, "version isn't a string or null"],
];
test("row 307 core refuses an answer outside the contract and appends nothing", (t) => {
for (const fx of [undefined, FIXTURES.known]) {
const f = setup(t, fx);
const before = f.ledger();
for (const [answer, what] of BAD) {
const message = `harness odd compactions: bad answer: ${what}`;
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => answer }), { message }, JSON.stringify(answer));
assert.equal(f.ledger(), before, JSON.stringify(answer));
}
// The module's own throw reaches the caller as it is, and nothing is written either.
const boom = new Error("boom");
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => { throw boom; } }), (e) => e === boom);
assert.equal(f.ledger(), before);
// A good answer is written: the check refuses only what's out of contract.
assert.equal(collectCompactions(f.store, { name: "odd", compactions: () => ok }, { now: () => "2026-10-22T12:00:09.000Z" }).at, "2026-10-22T12:00:09.000Z");
}
});
test("row 307 the module gets the store's two native directories, a copy of the cursors, the budget, the cap and first", (t) => {
const seen = [];
// The module writes into the cursors it got and answers the same cursor: core's ledger copy
// doesn't have it, so core writes it.
const harness = { name: "odd", compactions: (args) => (seen.push(structuredClone(args)), args.cursors.x = { bytes: 0, tail: HEX }, { ...ok, cursors: { x: { bytes: 0, tail: HEX } } }) };
const fresh = setup(t);
collectCompactions(fresh.store, harness, { budget: 123, streams: 45 });
const known = setup(t, FIXTURES.known);
collectCompactions(known.store, harness);
const store = (f) => ({ session: f.store.session, projects: f.store.projects });
assert.deepEqual(seen, [
{ store: store(fresh), cursors: {}, budget: 123, streams: 45, first: true },
{ store: store(known), cursors: { "a.jsonl": { bytes: 233, tail: "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a" } }, budget: 64 << 20, streams: 1024, first: false },
]);
assert.equal(readFileSync(join(known.store.dir, LEDGER), "utf8").split("\n").filter((l) => l.includes('"stream":"x"')).length, 1);
});
+3 -1
View File
@@ -11,6 +11,8 @@ import { join, relative, isAbsolute } from "node:path";
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
import { binds } from "../src/sandbox.mjs";
import { sessionStore } from "../src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
import { compactions as claude } from "../../plugins/harness/claude-code/index.mjs";
function setup(t, { legacy = false } = {}) {
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
@@ -18,7 +20,7 @@ function setup(t, { legacy = false } = {}) {
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
const collect = (harness = "pi") => collectCompactions(store, { name: harness, compactions: { pi, claude }[harness] }, { now });
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
return { root, store, collect, ledger, records };