Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e230f33
docs(plan): control history keeps a bounded window (#1274)
Juliusolsson05 Sep 27, 2026
436dc4a
fix(control): control history prunes old unkeyed calls and their payl…
Juliusolsson05 Sep 27, 2026
6517561
fix(control): retention keeps task origins, unsettled outcomes and qu…
Juliusolsson05 Sep 27, 2026
b3444f0
fix(control): retention survives GC failures, scans torn-tail quarant…
Juliusolsson05 Sep 27, 2026
84ed045
Merge remote-tracking branch 'origin/main' into fix/control-history-b…
Juliusolsson05 Sep 27, 2026
231ef4f
Merge remote-tracking branch 'origin/main' into fix/control-history-b…
Juliusolsson05 Sep 27, 2026
c64dfed
docs(plan): the remaining load- and build-dependent tests (#1107)
Juliusolsson05 Sep 27, 2026
36d38fa
test: wait on the load or the build, not on a budget meant for assert…
Juliusolsson05 Sep 27, 2026
70303a8
feat(control): 90-day control-history window, the owner's decision (#…
Juliusolsson05 Sep 27, 2026
c289d67
Merge remote-tracking branch 'origin/main' into fix/control-history-b…
Juliusolsson05 Sep 27, 2026
acc070d
test(control): pin the exact retention edge as kept (#1330 verification)
Juliusolsson05 Sep 27, 2026
d792da9
docs(plans): Codex compaction boundary (#1289)
Juliusolsson05 Sep 27, 2026
f523ff3
fix(codex): render committed compaction as a timestamped boundary
Juliusolsson05 Sep 27, 2026
31d01b3
Merge remote-tracking branch 'origin/main' into fix/control-history-b…
Juliusolsson05 Sep 27, 2026
ed37d2d
fix(orchestration): a compaction summary is not an agent message; doc…
Juliusolsson05 Sep 27, 2026
335408b
docs(plans): floating-surface layers, side-panel shell and unwired di…
Juliusolsson05 Sep 27, 2026
7773473
refactor(ui): named layers, one side-panel shell, RemotePanel with th…
Juliusolsson05 Sep 27, 2026
db908af
docs(setup): plan reporting failed setup-state writes (#1250 rows 6, 13)
Juliusolsson05 Sep 27, 2026
6707e7c
fix(setup): a failed setup-state write is restored and reported (#125…
Juliusolsson05 Sep 27, 2026
27ba05c
docs(activity): Codex compact boundaries carry a timestamp now (revie…
Juliusolsson05 Sep 27, 2026
42798c6
fix(ui): drop the unwired dismiss stack, correct the stacking comment…
Juliusolsson05 Sep 27, 2026
df5cd68
test(fixture): state that the compaction fixture omits the top-level …
Juliusolsson05 Sep 27, 2026
67d6c8f
docs(ui): stacking comments say open order, and SurfaceEntry.layer is…
Juliusolsson05 Sep 27, 2026
66a4674
test: pin lazy-prose's on-demand boundary; PathInput waits for its li…
Juliusolsson05 Sep 27, 2026
9370170
fix(setup): apply each save to the durable state; report only unsaved…
Juliusolsson05 Sep 27, 2026
f68c8e2
fix(control): serve the rewritten journal once its rename lands; pin …
Juliusolsson05 Sep 27, 2026
0ec09c1
docs(ui): registry comments describe the interleaved entries and posi…
Juliusolsson05 Sep 27, 2026
144b2f6
fix(setup): best-effort check write-back, ordered provider refreshes,…
Juliusolsson05 Sep 27, 2026
6c27420
fix(setup): publish provider snapshots from durable state; toolchain …
Juliusolsson05 Sep 27, 2026
cdee833
Merge #1330 into batch G
Juliusolsson05 Sep 27, 2026
f40b163
Merge #1377 into batch G
Juliusolsson05 Sep 27, 2026
1ec3f1f
Merge #1386 into batch G
Juliusolsson05 Sep 27, 2026
7b43931
Merge #1398 into batch G
Juliusolsson05 Sep 27, 2026
1205a20
Merge #1403 into batch G
Juliusolsson05 Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/plans/2026-09-26-control-history-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Control history keeps a bounded window (#1274)

## Problem
`FileControlHistory` appends every control call (external control, MCP, application tasks) to `control-history/events.jsonl`, with each prompt and result as a `payloads/<sha256>.json` file. Nothing is ever removed, and `open()` loads the whole journal into an in-memory array that every `history.events()` call copies.

## Evidence (owner's store, 2026-09-27)
- 129 MB on disk after 22 days: `events.jsonl` has 6,089 rows over 1,894 calls; `payloads/` holds 3,499 files for 3,457 referenced digests (42 orphans from failed appends).
- 105 MB of the payloads are `transcripts.page` results, which are read-only and unkeyed. `mcp.tools/list` adds 7 MB, `mcp.tools/call` 2.8 MB, and `agents.read` 1.5 MB.
- Keyed calls, the executor's dedupe ledger (#1240), are small: 815 rows, 220 `received` (221 calls carry a key; corrected in review C3). Their keys come from `dispatch.configure`, `commands.run`, `operations.start/finish`, `agents.prompt`, and others.
- Every call in the journal has a `result` row (0 in flight).
- Simulated retention for unkeyed finished calls: 7 days keeps 507 calls (1,910 rows) and 17 MB of payloads; 14 days keeps 881 calls and 48 MB; 30 days keeps everything.

## The boundary this must not break
The journal is the executor's idempotency ledger (see the #1240 class comment and `docs/plans/2026-09-25-control-history-recovery.md`). A keyed retry must find its `received` row and stored result forever, because request keys have no lifetime in the contract. Retention may therefore only remove calls that can never be looked up for dedupe.

## Decisions (defaults)
1. **Prune whole calls, never single rows.** A call is removed only when ALL of these hold:
- no row of the call carries a `requestKey`, so it is not dedupe evidence;
- it has a `result` row, so it is not in flight (`history.read` would otherwise show a call lost mid-way);
- no kept row names it as `reusedCallId`;
- its newest row is older than the retention window.
Removing whole calls keeps the per-call key consistency that `analyze()` checks, so a pruned ledger reloads as clean.
2. **Window: 7 days (UNCONFIRMED default).** On the owner's rate this is about 20–35 MB steady state. `history.read` and `history.list` answer for the last week; an agent inspecting what it did is interested in hours, not weeks. The window is a constructor option so a product change is one line.
3. **Pruning runs on load, after recovery,** as one atomic rewrite of `events.jsonl` (temp file, fsync, rename, directory fsync, the same `writeAtomic` recovery uses), with sequences renumbered (process-local cursors, as in recovery). The app restarts often (updates, relaunch), and load is the one point where no append is in flight. A process that runs for weeks keeps growing until its next launch; that residual is stated.
4. **Payload GC after the rewrite.** Delete every `payloads/*.json` not referenced by a kept row, which also removes orphans left by failed appends. This runs only after the journal rewrite is durable, so a crash in between leaves extra files, never a row pointing at a missing payload. A digest shared by a pruned row and a kept row stays.
5. **`history.list` drops "never silently truncated".** It becomes a stated window: "keyed calls are kept; other finished calls are kept for 7 days".
6. **A damaged ledger is not pruned in the same launch.** When recovery ran, the rewritten ledger is still under an unaccepted block; pruning is skipped so the operator reconciles what they saw. The next clean launch prunes.

## Tests (fail-first, real rows)
A fixture of real journal rows, recorded from the owner's store: ids, timestamps, callers, capability ids and digests only (no prompts, per the #1240 plan). It covers old unkeyed calls, old keyed calls, a reuse pair, and recent calls. Payload files are written by the test under the recorded digests.
- An old unkeyed finished call is pruned, with its payloads, and orphan payloads are removed. Old keyed calls, the calls they reuse, and recent calls are kept, byte-identical apart from renumbered sequences.
- After pruning, a keyed retry of an old key still replays its stored result and never re-dispatches (executor round-trip).
- A call without a `result` row is kept however old it is.
- A payload shared by a pruned and a kept row survives.
- A load that ran recovery does not prune.

## Review round 1 (#1330) and steering q47/q49
All three reviewers returned FIX-BEFORE-MERGE. Whatever window the owner picks, retention must never delete evidence someone can still ask for. A finished, unkeyed, unreused, old call is now also KEPT when:
- **It is a lifecycle task origin** (a `task.*` step) (A1, C1). `operations.read`/`finish` look a task up by its original call id, and the tool contract says task results persist across restarts. The owner has 12 such calls.
- **Its result is not proven settled** (A3). Settled means: an ok result with status `completed`/`ui_opened`, a `not_started` refusal, or an MCP transport echo. `outcome_unknown` (26 on the owner's machine) and `pending` (157, accepted `agents.prompt` operations) are kept, and so is a missing or unreadable payload (q40).
- **An unaccepted recovery quarantine names it** (A2). Its call ids, and every payload digest the quarantine names, stay until the operator accepts that digest in `recovery-accepted.json`.

Payloads are read only for calls that are otherwise expired, so after the first launch each launch reads about a day's worth.

Other changes:
- **Rewrite failure (B1).** A failed rewrite no longer fails the load, and payload GC never runs after one. Sequences are renumbered on copies, so the rows served still match the unchanged file.
- **Survivors pinned.** Reuse-only target (A, B2), unknown-age row (A, B3), non-digest temp file (B4), and the window at its boundary, 7 days ± 1 minute (C).
- **Fixture metadata (B5).** The shared digest is on the `dispatched` rows.

**Owner question (unchanged, still open):** the window length (default 7 days). With the retained roots above, what a window deletes is only settled, unkeyed, non-task calls: exact request/result copies of reads and completed mutations, which `history.read`/`list` then no longer find. On the owner's store today: 7 days keeps ~520 calls and ~20 MB of payloads; 14 days keeps ~884 calls and ~52 MB.

## Review round 2 (#1330)
- **A GC failure after a successful rewrite (a, b, c; Blocker).** The whole prune rejected, and `open()` served the pre-rewrite rows. The next append numbered itself from that longer list, and the gap got the journal quarantined with keyed calls blocked. Once the rewrite lands, the rewritten rows are always what is served. Each payload deletion fails on its own (warned, retried next launch).
- **A torn-tail quarantine (a, b, c; Major).** It has no guard (it blocks no keyed call), so it was never scanned, and the payload only its torn line names (an outcome fsynced before the append tore) was deleted on the next launch. Evidence is now read from **every** quarantine file whose digest is not accepted. A damaged line still yields its digests, and its call id when visible. An unreadable quarantine file skips retention for that launch.
- **Task detection by byte prefix (a, b, c).** It missed a valid step with another key order and treated a corrupted step as ordinary. It now parses the step through the integrity-checked `payload()`, as the task store does. Unreadable or not an object means kept.
- **First-launch cost (b, c; Minor): accepted as a residual.** Classifying the backlog reads each old result payload once. On a clone of the owner's store the first load took 3.2–6.9 s, and later loads take about 35 ms. It runs on the first control-history use after the upgrade, not at app start. A byte-sniffing shortcut was rejected: that is exactly the kind of guess the task-prefix bug was.

Tests: 3 new cases (red on `65175615`). They also kill round 2's surviving mutations: the task guard is now exercised with a settled result, and quarantine-only digests with a torn tail.

## Owner decision (2026-09-27)
**The window is 90 days** (`temp/manager/owner-decisions-2026-09-27.md`: "#1330, control history: 90 days", with the standing preference "infrequent deletion"). This replaces the UNCONFIRMED 7-day default.
- `CONTROL_HISTORY_RETENTION_MS = 90 days`, pinned by value in the boundary test.
- Both `history.read` / `history.list` descriptions now say 90 days.
- **Tests:** the recorded-store retention cases move "now" 83 days later, the amount the window grew. Every recorded call keeps its position relative to the edge, so the same rows are pruned and kept.
- **Cost:** on the owner's store today, 30 days already kept everything, so the 90-day window deletes nothing yet. At ~5 MB of payloads a day, steady state is up to ~450 MB. What stays bounded is long-run growth of a journal held whole in memory.

## Verification (b: FIX-BEFORE-MERGE; c: MERGE-READY)
- **b (Blocker): the same served/disk divergence through the rewrite's directory sync**, which runs after the rename. EIO there rejected the prune, and open() served the pre-rewrite rows.
- **Ruling:** the rows on disk change at the rename. `writeAtomic` reports when the rename landed. A failure after that point serves the renumbered rows, with a warning, and skips payload GC for this launch.
- **Why skip GC:** a power loss could undo the unsynced directory entry and bring back the old journal, which names the expired calls' payloads. The orphans are collected by the next launch.
- Test: EIO injected at the directory sync after the rename. The served rows match the file, the expired call's payloads remain, the next append continues the file, and the next launch reports no recovery. The test fails on the previous head.
- **b (survivor): the GC's quarantine digests were unpinned alongside a real prune.** Test: a torn-tail quarantine, then another old settled call pruned in the same launch; the quarantine-only payload survives. The test fails with the digests removed from GC's `referenced` set.
24 changes: 24 additions & 0 deletions docs/plans/2026-09-27-codex-compaction-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Codex compaction never renders (#1289)

## Evidence (verified 2026-09-27 on the local corpus, about 1,500 `compacted` lines)
1. **The branch never runs.** `mapCodexRolloutToFeedEntries` returns early when `payload.type` is not a string. A `compacted` line's payload has no `type`, so the `entry.type === 'compacted'` branch never runs and Codex compaction never renders.
2. **The boundary has no timestamp.** The boundary entry is built without one, and `rendering/model/order.ts` sorts timestamp-less rows to the end of their phase. So enabling the branch as-is would paint "Conversation compacted" at the bottom of the feed, not where it happened.
3. **`replacement_history` is not new conversation.** It is the context Codex retains across the compaction: developer instructions, the AGENTS.md block, earlier user prompts and (0.155+) a `compaction` item whose summary is `encrypted_content` (13–23 KB). Mapping it repaints prompts already in the feed (#1289: 17,326 of 20,343 sampled replacement messages duplicate an earlier user message).
4. **The summary `message` is empty in every 0.15x rollout.** Only 56 of about 1,500 compactions carry readable text, all from older CLIs.
5. **The boundary stores the whole payload as `compactMetadata`.** That is `replacement_history`, the retained user prompts, resume metadata and the encrypted blob, kept on a feed entry and in every debug bundle, although nothing reads more than identity.

## Change
- Handle `compacted` before the `payload.type` guard.
- The boundary carries the line's timestamp, so it sorts where compaction happened.
- `replacement_history` is never mapped. The summary entry is emitted only when `message` is non-empty (older CLIs).
- **No `compactMetadata`.** Nothing in the app reads it for Codex, and a varying metadata object also made every boundary a different rendering shape.
- Catalog: add Codex durable shapes for the boundary and the summary. They are the same shared.compaction dispositions Claude's entries have, pinned by curated fixtures.

## Tests
Two real `compacted` lines: a 0.157.0 one (empty message, `compaction` item) and an older one with a readable message. Texts are redacted at equal length; the structure and keys are verbatim.
- Both map to a timestamped boundary.
- Only the older one also maps to a summary.
- No replacement-history entry is emitted.
- The boundary carries no retained history.
- Red on main, where both map to `[]`.
- The dispatcher renders the boundary through `shared.compaction`, and the catalog classifies it `known-claimed`.
42 changes: 42 additions & 0 deletions docs/plans/2026-09-27-floating-surface-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Floating-surface layer: named layers, side-panel shell (#512)

## Where #512 stands on main (inventory, 2026-09-27)
- **Done by earlier work:** shared Radix `dialog.tsx` for modals, shared `dropdown-menu.tsx` (AppearanceMenu and the Skills menu moved onto it), one toast (CaffeinateToast forwards to `GlobalToast`), and the shared side-panel header `PanelHeader`.
- **Left:**
1. **No named layer scale.** The bands are real but spelled as magic numbers in about ten files:
- pane overlays z-40/50;
- pane dialog z-[60..62];
- dialog z-[1100];
- menus z-[1150];
- toast/dictation z-[1200];
- debug highlight z-[10000].
Comments in `registry.tsx`, `App.tsx` and `surfaces/types.ts` still describe the old "everything z-50, DOM order breaks ties" model.
2. **No shared side-panel shell.** Git and Worktrees are identical; Agent Status is near-identical. The six debug panels copy their own shell and header, and their closes have no accessible name.
3. **RemotePanel is registered as a side panel but renders a centered Dialog.**
4. **No dismiss stack / `anySurfaceOpen()`.** Escape arbitration is Radix's own layer stack, plus the central `useKeybinds` branches, plus per-component handlers.
5. **Three hand-rolled anchored popovers remain:**
- CommandSortControl: keeps focus in the palette input;
- PathInput suggestions: a combobox;
- ExplorerPane context menu: pointer-anchored, with a WHY for not moving.

## This PR
- **`ui/layers.ts`:** ONE table of named layers as literal Tailwind classes (Tailwind only emits classes it sees spelled out, the rule `PANE_DIALOG_LAYERS` already follows). Every magic z value in the bands above moves onto it, and `PANE_DIALOG_LAYERS` becomes a view of it. The stale comments are corrected.
- **`components/ui/side-panel.tsx` `<SidePanel>`:** the shared outer shell (`aside`, fixed width, border, surface, column, overflow). Git, Worktrees, Agent Status and the debug panels render through it. The debug panels also move to `PanelHeader`, which gives their closes a name.
- **RemotePanel** moves from `sidePanelSurfaces` to the modal surfaces it actually is.
## Sequencing (steering q95)
W1's open #1394 edits `useKeybinds.ts` and its ownership tests, and owner keyboard work (#1221 follow-ups) is in flight. Until #1394 merges, this PR does NOT touch `useKeybinds.ts` or its tests.

## The dismiss stack moved out of this PR (review b)
A first version shipped an unwired `ui/dismissStack.ts`. Review showed its wiring contract could not work as written:
- **Capture phase is too early.** A document capture-phase Escape listener runs BEFORE the inner handlers that implement two-phase dismissal (PathInput suggestions, then the modal; palette sub-modes), so it would close the modal first.
- **Effect order is not stacking order.** Passive-effect registration order is not visual stacking order: a parent and child that mount in the same commit register child-first, so Escape would close the parent.

Both need a different arbitration design, done together with the `useKeybinds.ts` wiring on a fresh origin/main after #1394 merges. Shipping an unwired module with a known-wrong contract would mislead that work.

## Not in this PR, and why
The three remaining anchored popovers stay custom. Each has a documented reason a menu primitive does not fit:
- the palette input must keep DOM focus;
- the path field is a combobox;
- the explorer menu is anchored at the pointer.

A popover primitive shaped around one of them would be fitted to one caller. The PR therefore says `Refs #512`, not `Fixes`. #512 stays open for the dismiss stack plus its useKeybinds wiring, and for the popover decision.
Loading
Loading