Bound workstream startup to the recent tail (#21352)
Summary
Fixes #21352. Feed startup read the whole append-only ~/.cmuxterm/workstream.jsonl into memory and decoded every row, twice: once to count rows for the revision (loadRevision) and once to find the latest items (loadLatest). The log is never compacted, so this cost grows with every session. On a 3.1 GB log, WorkstreamStore.start() takes about 72 s and peaks at 10 GB of memory before the Feed is usable.
Startup now reads a bounded tail from the end of the file and keeps the newest version of each item in it. Older history pages in on demand with byte-offset cursors, as loadOlderItems() already did. Startup cost no longer depends on history size: about 0.1 s and 40 MB on the same 3.1 GB log.
What changes:
- Startup tail. Reads backwards from EOF until it has enough rows (at most 8 MiB + 1 byte) and collapses duplicate IDs in that tail. A row over the existing 8 MiB cap is skipped and never buffered whole; if the newest row alone is that large, startup pages past it rather than showing an empty Feed. An ordinary row that straddles a read window is kept whole: the next page reads it from its start.
- Budget.
AppDelegatepasses a 5 s startup budget, which bounds how long the Feed waits on a slow disk (the load already ran off the main actor). If it expires, the store publishes what it has read and the paging cursor still covers every row, so a slow disk delays history, it doesn't lose it. - Revision. The feed revision becomes a byte-count generation (it grows with every append, so it no longer needs a scan) with a durable high-water sidecar,
<log>.revision, for transitions that change the remote validator without appending. It doesn't go backwards across an orderly restart orcmux feed clear; a crash with a sidecar write still pending can lose at most the bumps that didn't append. - Mobile revision.
combinedMobileFeedRevisionkeeps the packed namespace older phones may have cached, with the notification lane pinned to its maximum. The result is never below a value an older Mac sent for the same workstream revision, and it no longer depends on whether notification history has loaded yet; on main it returned the bare workstream value before notifications loaded and the packed value after, so it could move backwards. Byte revisions outgrow the 32-bit lane, so past 1 GiB it continues linearly instead of shifting into the sign bit. - Pending items. Pending items restored by the async load are reconciled once it finishes, so the ones whose agent process is gone expire at launch. On main,
installreconciled before the load completed, so they stayed pending. - Quit and clear. Orderly quit flushes pending journal and sidecar writes, inside the existing
.agentTerminationcleanup deadline.cmux feed cleargoes through the persistence actor so the revision floor survives a clear.
Overlap with #22901. That PR adds a request-ID index to WorkstreamStore and rebuilds it in start(), which this PR rewrites. The two conflict only there. Whichever lands second needs rebuildRequestIndex() after the startup snapshot (and any items ingested during the load) are applied; I'll rebase this one if #22901 lands first.
Testing
Startup cost, main vs this branch. A small executable links each tree's CMUXAgentLaunch, writes a synthetic history through the package's own encoder (about 1.6 KB per row, close to a real log), and times WorkstreamStore(persistence:).start() the way AppDelegate calls it, with /usr/bin/time -l for peak RSS. Each run gets a fresh APFS clone of the same file. Three runs each on an M5 Mac Studio that was otherwise busy (load average about 100), Release build:
| History | main start() | main peak RSS | This branch start() | This branch peak RSS |
|---|---|---|---|---|
| 80 MB, 50k rows | 2.1 s | 303 MB | 0.10 s | 44 MB |
| 1.0 GB, 640k rows | 24–28 s | 3.3 GB | 0.09–0.13 s | 39 MB |
| 3.1 GB, 1.92M rows | 71–76 s | 10.1 GB | 0.10–0.11 s | 39 MB |
Both trees publish the same 300 newest items.
Benchmark harness
// Package.swift: an executable target depending on Packages/macOS/CMUXAgentLaunch.
// The branch build passes startupHardBudgetNanoseconds: 5_000_000_000 like AppDelegate.
import CMUXAgentLaunch
import Foundation
let args = CommandLine.arguments
switch args[1] {
case "generate": // bench generate <file> <rows>
let persistence = WorkstreamPersistence(fileURL: URL(fileURLWithPath: args[2]))
let input = String(repeating: "{\"file_path\":\"/Users/dev/Code/project/Sources/Module/File.swift\",\"old\":\"x\"}", count: 14)
for index in 0..<Int(args[3])! {
try await persistence.append(WorkstreamItem(
workstreamId: "codex-session-\(index % 400)",
source: index % 2 == 0 ? .codex : .claude,
kind: .toolUse,
createdAt: Date(timeIntervalSince1970: 1_790_000_000 + Double(index)),
cwd: "/Users/dev/Code/project",
title: "Synthetic session \(index % 400)",
payload: .toolUse(toolName: "apply_patch", toolInputJSON: input)
))
}
default: // bench start <file>
let clock = ContinuousClock()
let started = clock.now
let store = WorkstreamStore(persistence: WorkstreamPersistence(fileURL: URL(fileURLWithPath: args[2])))
await store.start()
print("start=\(clock.now - started) items=\(store.items.count)")
}
Package tests. swift test --package-path Packages/macOS/CMUXAgentLaunch: 893 tests in 101 suites passed on this head. New coverage in WorkstreamPersistenceTests and WorkstreamStoreStartTests includes:
- every row reachable exactly once through startup plus paging when rows are about 30 KB (so 300 rows overflow one read window);
- a sweep of startup budgets from 0 to 20 ms, where wherever the budget expires the published items plus the pages before the cursor cover all 3,000 rows;
- oversized, malformed and torn rows, including an oversized newest row;
- revision floors across restart, clear and an oversized sidecar.
App target. scripts/test-unit.sh build-for-testing (cmux-unit) builds the app and cmuxTests. python3 scripts/verify-local.py --affected passes all 6 selected checks.
Observed / Not run
- Observed: the measurements above; the package suite passing; the app and test target compiling.
- Not run:
cmuxTestswere compiled, not executed. That covers theFeedCoordinatorTestsrevision cases and thePiFeedDockOwnershipTestscall-site update. A unit-test host on a developer Mac installs the Feed store on the real~/.cmuxterm/workstream.jsonl, so I didn't run them outside an isolated machine. - Not run: a live tagged-app launch against a multi-GB history.
- No red test on
main: the bug is startup cost, which the measurement shows; there's no unit test that fails onmainfor it.
Changelog
Fixed: cmux no longer stalls at launch, or uses gigabytes of memory, reading a long Feed history; startup now loads only recent history and pages in the rest.
Proof
No UI change; the evidence is the measurement above.
Checklist
- Behavior changes have added or updated tests
- No new user-facing strings
- Mobile RPC contract: the
feed.list/feed.changedrevision keeps its packed namespace and only moves forward relative to any value an older Mac sent (mobileFeedRevisionCoversLegacyPackedValues, the cutover tests), so existing phone clients' cache validation still applies. - Reviewed with independent review passes before opening; findings fixed in this commit
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Fixes Feed startup reading the entire append-only ~/.cmuxterm/workstream.jsonl into memory and decoding every row twice, which on a 3.1 GB log took about 72 s and peaked at 10 GB of memory. Startup now reads a bounded tail from the end of the file, keeps the newest version of each item in it, and pages older history in on demand, bringing the same startup down to about 0.1 s and 40 MB.
- Rows over the 8 MiB cap are skipped without buffering. If the newest row alone is oversized, startup pages past it instead of showing an empty Feed. An ordinary row that straddles a read window is kept whole.
- A 5 s budget bounds Feed startup on slow disks, and an expired budget leaves the paging cursor covering every row, so a slow disk delays history instead of losing it.
- The feed revision becomes a byte-count generation with a durable high-water sidecar (
<log>.revision) that survives restart andcmux feed clear; the mobile-compat packed value pins the notification lane and continues linearly past 1 GiB. - Restored pending items are reconciled after the async load, so items whose agent process is gone expire at launch.
- Overlapping recovery pages are deduplicated and materialized, so each item's newest status and reply survives recovery, including malformed and torn rows.
- Payloads are redacted at the journal boundary before encoding.
- Orderly quit defers termination while journal or sidecar writes are in flight, so the cleanup task flushes them first; a pending write is no longer skipped when nothing else blocks quit, and failed journal appends and sidecar writes are logged.
Summary by CodeRabbit
- Bug Fixes
- Feed history now loads within a bounded time, even when logs contain malformed or unusually large entries.
- Restored feed items and older history are handled more reliably, and interrupted log entries are repaired during later updates.
- Clearing the feed preserves revision continuity, and pending items are reconciled after startup.
- Feed updates use consistent revision tracking, and feed data is flushed during app shutdown to help preserve recent changes.