perf(acpmux): index background process identities for 3,000-entry replay (SW-11)

Merged
TE
5d ago
+106-13

SW-11: index the background process registry by stable ID and tool-call ID

Tracks the SW-11 follow-up on the daemon's process-history cost and its HQ result.

Root cause

ProcessTable keeps every running/completed process in an append-order Vec<Process>. On every newly observed background task, fold searches that entire vector for its toolCallId; unused_id scans it again to avoid a reused task name. During daemon adoption read_processes replays every background task and upsert linearly searches again for each persisted record. An independent 3,000-process history therefore creates millions of comparisons. Stop/tail and late output-file updates perform additional linear scans.

Change

  • Add per-session, append-slot indices: by_id: HashMap<String,usize> and by_tool: HashMap<String,usize>. Original vector remains the authoritative source for row ordering and process state; indices point into it only.
  • Route all appends (including process replay) through ProcessTable::insert; use first-match tool lookup in fold and recorded id lookup in upsert, tail_process, kill_process and the late-output-file path.
  • When a logged process_update corrects the tool-call ID of an existing process, repair the earliest-key mapping. That exceptional correction may scan; ordinary insert, lookup, replacement, and ID-collision detection now use average O(1) hash lookups.
  • Rebuild both indices as part of the existing fresh ProcessTable on session log read/adoption, so no additional persistent state, disk contract, or migration. Preserve old id-2, id-3 collision naming; the suffix loop is still proportional to the number of collisions for the same base ID.

Regression

New processes_index_tests.rs covers:

  • Two records sharing a tool call: lookups continue to point to the first matching row;
  • A persisted correction that moves that first record to another tool-call ID, with mapping repaired to the next eligible row;
  • Reused process IDs and suffix allocation;
  • 3,000 replayed process records, correct slot counts and unchanged stable IDs after a later repeated update.
    Existing tests/hub_integration/processes.rs and codex_processes.rs must cover stop, output-tail and re-adoption behavior end-to-end.

Performance

Same harness at base and head: an ignored #[test] (source below) that replays N background-process records through ProcessTable::upsert, replays each again (a second logged process_update), then allocates an id per row with unused_id, as fold does. Best of 5, cargo test --release -p acpmux --lib processes_replay_bench -- --ignored --nocapture through cmux-ci test (32 vCPU Linux). Base is 350dd620b91f (this PR's merge base) with only the bench module added to processes.rs.

metricbeforeafterdeltaharness or run link
replay 3,000 processes (ms, best of 5)18.7891.628-91% (11.5x)base run / head run
replay 10,000 processes (ms, best of 5)320.5065.887-98% (54x)same runs

Memory: two HashMap<String, usize> per session, one entry each per process (a cloned id and tool-call id string plus a slot), so roughly 100-150 bytes per process; about 0.4 MB for 3,000 processes. Not measured separately.

Bench source (not committed)
//! Same-harness timing of a long background-process replay: `cargo test
//! --release -p acpmux --lib processes_replay_bench -- --ignored --nocapture`.
//! Uses only `upsert` and `unused_id`, so it runs unchanged on the pre-index base.
use super::*;

fn record(i: usize) -> Process {
    serde_json::from_value(json!({
        "id": format!("session/task-{i}"), "sessionId": "session", "toolCallId": format!("tool-{i}"),
        "taskId": "task", "harness": "claude", "command": "true", "cwd": "/tmp",
        "startedAt": 1, "state": "running", "origin": "background"
    }))
    .unwrap()
}

#[test]
#[ignore = "timing harness; run with --ignored --nocapture"]
fn processes_replay_bench() {
    for n in [3_000usize, 10_000] {
        let records: Vec<Process> = (0..n).map(record).collect();
        let mut best = std::time::Duration::MAX;
        for _ in 0..5 {
            let mut table = ProcessTable::default();
            let start = std::time::Instant::now();
            for p in records.iter().cloned() {
                table.upsert(p);
            }
            // Each replayed record logged again (a second process_update), then an id
            // allocation per row, as `fold` does for a newly seen task.
            for p in records.iter().cloned() {
                table.upsert(p);
            }
            for i in 0..n {
                std::hint::black_box(table.unused_id(format!("session/task-{i}")));
            }
            best = best.min(start.elapsed());
            assert_eq!(table.processes.len(), n);
        }
        println!("BENCH processes_replay n={n} best_of_5_ms={:.3}", best.as_secs_f64() * 1e3);
    }
}

Boundaries / validation

  • Branched from feat-cmux-next@ebda72d304f33baa4e3346bba2b2b99bbb1c53b6. Two changed files (one Rust module, one new unit-test module), no API, UI, network or format changes.
  • Shares the hub/processes.rs ownership path with our earlier #29238 mutex release patch, but edits separate sections. Merge/rebase in order and rerun the test suite against the combined head before landing; do not independently land both without confirming that shared file.
  • Required checks (run in this PR's CI): cd cmux-tui && cargo test -p acpmux --lib processes_index_tests, cargo test -p acpmux --test hub_integration -- process, cargo fmt --check, Clippy and managed NEXT gates.

Capture

No capture: daemon-only lookup implementation with unchanged process-list serialization and CLI/UI responses.

Changelog: Background-process history uses stable in-memory lookup indices so large replay histories avoid repeated full-vector searches.