Files

14 KiB

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).