Skip to content

perf: stop v2 host-service from scaling linearly with worktree count#3999

Merged
Kitenite merged 12 commits into
mainfrom
v2-paths-worktree-perf
May 4, 2026
Merged

perf: stop v2 host-service from scaling linearly with worktree count#3999
Kitenite merged 12 commits into
mainfrom
v2-paths-worktree-perf

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented May 3, 2026

Summary

Three steady-state costs in v2 host-service grew linearly with worktree count, even when worktrees were idle. After this branch, host-service idle CPU and JS heap stay roughly flat as N grows.

  • Bump react-dom from 18.3.1 to 19.2.0 #1 (host-service): event-driven pull-requests sync. Replaced the 30 s syncWorkspaceBranches polling timer with a GitWatcher.onChanged subscription. Idle worktrees now do 0 git subprocesses; branch changes propagate at ~430 ms p50 (was up to 30 s). syncWorkspaceBranches survives as a 5-min safety-net sweep for GitWatcher overflow / error paths. PROJECT_REFRESH_INTERVAL_MS also drops from 20 s → 5 min (Fix Bump tailwind-merge from 2.6.0 to 3.3.1 #4 bundled in).
  • Bump lucide-react from 0.468.0 to 0.546.0 #2 (workspace-fs): LRU + 30-min idle TTL on searchIndexCache. Hard-caps cached indexes at 12 (was unbounded across all worktrees ever opened).
  • Bump react from 18.3.1 to 19.2.0 #3 (workspace-fs): LRU cap on per-watcher pathTypes. Hard-caps at 10 k entries (was unbounded — log rotation / hashed build artifacts leaked entries forever).

GitWatcher was lifted out of EventBus into a standalone instance in app.ts so both EventBus (broadcasts to clients) and PullRequestRuntimeManager share one watcher.

Companion docs in plans/v2-paths-worktree-perf-findings.md and plans/v2-paths-worktree-perf-fix-plan.md have the full audit, methodology, and per-fix targets.

Measured impact

Workspace-fs caps (cache-and-paths-memory.bench.test.ts):

Metric Before After
Heap @ 130 cached worktree indexes +6.87 MB +2.02 MB (71% drop)
Heap @ 20 k unique paths in pathTypes +8.69 MB +2.54 MB (71% drop)
pathTypes.size @ 20 k unique paths 20,000 10,000 (capped)
searchIndexCache retained @ 130 worktrees 130 (linear) 12 (cap)

Pull-requests runtime (pull-requests-scaling.bench.test.ts, real DB + real git):

Metric Before After
Idle tick @ N=20 1450 ms / 30 s tick 0 ms (no idle ticks)
Branch change → DB update latency up to 30 s 427 ms (N=5)
Daily safety-net cost @ N=20 70 min/day (2880 ticks) 7 min/day (288 sweeps)

Test plan

  • bun test from packages/host-service — 460/460 passing, including the new event-driven steady-state integration test (commits in 1/5 worktrees → exactly 4 git ops on 1 worktree)
  • bun test from packages/workspace-fs — 43/43 passing
  • bun run lint clean
  • bun run typecheck clean for host-service (the workspace-fs test typecheck errors flagged by turbo are pre-existing — bun:test types in test-only files, unrelated to this branch)
  • Manual smoke: open ≥ 10 worktrees, leave host-service idle for 10 min, verify CPU baseline ≤ 1% and RSS stable
  • Manual smoke: branch change in one worktree → workspaces.headSha updates within ~1 s in the renderer

Summary by cubic

Stop v2 host-service from scaling with worktree count. Pull‑requests sync is now event‑driven via GitWatcher; caches are capped with TTL, directory watch hints are preserved, per‑workspace syncs serialize/coalesce, and watcher normalization is serialized. Idle CPU and heap stay flat, and branch changes update in ~430 ms.

  • Refactors

    • packages/host-service: drive branch sync from GitWatcher.onChanged; replace the 30s poll with a 5‑min safety‑net sweep; create a single GitWatcher in app.ts and inject into EventBus and the PR runtime; add syncOneWorkspace; serialize and coalesce per‑workspace syncs via a queue; route the safety‑net sweep through the same queue; drop project refresh to 5 min; slim the integration test to only the event‑driven scenario to avoid CI OOM (scaling covered by unit tests).
    • packages/workspace-fs: add LRU + 30‑min idle TTL to searchIndexCache (cap 12; TTL enforced on hits); remove the redundant TTL sweep; split path tracking into capped filePaths (10k) and uncapped directoryPaths to preserve delete semantics; serialize watcher event normalization; add tests/benches and deflake where needed.
  • Dependencies

    • Add bun-types devDependency in host-service and workspace-fs; drop the local bun-test.d.ts shim; move heavy benches behind explicit bun run bench scripts to keep bun test lean.

Written for commit 6a6fac2. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Pull-request sync is now event-driven (responds to git changes) with a periodic safety-net sweep; app lifecycle starts and stops a single git-change watcher used across components.
  • Performance

    • Greatly reduced background git subprocess work, capped per-watcher path tracking to bound memory, and added LRU + TTL eviction for search index cache.
  • Tests

    • Added integration and benchmark suites validating scaling, memory, and watcher-driven steady state.
  • Documentation

    • Added a performance audit and remediation plan for worktree scaling.

Kitenite added 3 commits May 2, 2026 17:25
…ling

Both maps previously had no eviction and grew monotonically with worktree
count (searchIndexCache) and file event count (pathTypes). Adds LRU(12) +
30-min idle TTL to searchIndexCache and LRU(10k) cap to per-watcher
pathTypes. Eviction in pathTypes loses only the directory-type hint; the
next event for that path falls back to stat() (existing slow path).

Measured (cache-and-paths-memory.bench.test.ts):
- searchIndexCache @ 130 worktrees: 6.87 MB → 2.02 MB (-71%)
- pathTypes @ 20k unique paths: 8.69 MB → 2.54 MB (-71%)
- searchIndexCache cap holds at 12 entries, pathTypes at 10000

Adds findings audit + fix plan + reproduction tests + benchmarks for the
broader v2 worktree-perf investigation. Notably, the host-service
syncWorkspaceBranches 30s polling (1542 ms/tick at N=20 worktrees, real git
subprocesses) is documented and reproduced but not fixed in this commit;
follow-up PR will subscribe the pull-requests runtime to GitWatcher.onChanged.

See plans/v2-paths-worktree-perf-findings.md for the full audit and
plans/v2-paths-worktree-perf-fix-plan.md for the remaining work.
…notes

Adds concrete pickup steps, app.ts wiring order, concurrency notes, and a
mapping of which existing tests/benchmarks change vs stay. The next session
(or fresh agent) implementing Fix #1 should be able to read the plan
top-to-bottom and execute without re-deriving context.
Replaces the unconditional 30s `syncWorkspaceBranches` polling timer with a
`GitWatcher.onChanged` subscription so idle worktrees cost ~0 git
subprocesses regardless of N. Branch / HEAD / upstream changes are picked
up at ~430 ms p50 (was up to 30 s).

Lift `GitWatcher` to a standalone instance in `app.ts` so both `EventBus`
(broadcasts to clients) and `PullRequestRuntimeManager` (event-driven
branch sync) share one watcher.

`syncWorkspaceBranches` becomes the safety-net sweep: still O(N) per call,
but cadence drops from 30 s → 5 min. Project-level PR refresh interval
also drops from 20 s → 5 min — branch changes drive their own
`refreshProject` via `syncOneWorkspace`, so the polling is only there to
catch external PR opens.

Concurrency stays safe via the existing `inFlightProjects` guard. Workspace
deletion races no-op cleanly via a fresh row lookup in `syncOneWorkspace`.

Tests updated:
- Existing scaling unit + integration tests now describe the safety-net
  sweep (still pin the O(N) per-call shape).
- New integration test wires a real `GitWatcher` + `WorkspaceFilesystemManager`
  and asserts a `git commit` in 1/5 worktrees triggers exactly 4 git ops
  on 1 worktree.
- Bench replaces "ms per polling tick" with event-to-DB-update latency
  (427 ms measured) plus the long-cadence safety-net sweep cost.

Closes Fix #1 + Fix #4 in plans/v2-paths-worktree-perf-fix-plan.md.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

createApp now starts a single GitWatcher and injects it into PullRequestRuntimeManager and EventBus. Pull-request syncing is event-driven via gitWatcher.onChanged with per-workspace coalescing and 5‑minute safety/project backstops. Search index cache gains TTL+LRU eviction. Watcher file-path tracking is capped at 10,000. Tests and benches added/updated.

Changes

Worktree Performance Scaling — Event-Driven Sync & Memory Caps

Layer / File(s) Summary
Boot & Wiring
packages/host-service/src/app.ts
App constructs/starts one GitWatcher, injects it into PullRequestRuntimeManager and EventBus, and best-effort closes it on dispose.
Events Surface
packages/host-service/src/events/index.ts
Re-exports GitWatcher and GitChangedEvent.
EventBus Options / Tests
packages/host-service/src/events/event-bus.ts, packages/host-service/src/events/event-bus.test.ts
EventBusOptions now requires an injected gitWatcher; constructor assigns it; start() no longer constructs watcher; close() no longer closes watcher; tests updated to pass a gitWatcher stub.
Pull Requests Runtime: wiring
packages/host-service/src/runtime/pull-requests/pull-requests.ts
PullRequestRuntimeManagerOptions added gitWatcher; start() subscribes to gitWatcher.onChanged, performs initial sweep, and starts two 5‑minute timers; stop() clears timers and unsubscribes.
Pull Requests Runtime: control flow & sync
packages/host-service/src/runtime/pull-requests/pull-requests.ts
Replaces short-interval polling with watcher-driven per-workspace enqueueWorkspaceSync (coalescing/serialization), adds syncOneWorkspace and syncWorkspaceRow, and refactors syncWorkspaceBranches() to enqueue all workspaces through the same queue.
Search Index Cache shape & eviction
packages/workspace-fs/src/search.ts, packages/workspace-fs/src/search-cache-eviction.test.ts
Cache entries now { items, lastAccessedAt }; adds TTL stale eviction and LRU eviction when exceeding max; getSearchIndex enforces TTL and bumps MRU; tests added for LRU/TTL/invalidation.
pathTypes cap & eviction
packages/workspace-fs/src/watch.ts, packages/workspace-fs/src/watch-pathtypes-growth.test.ts
Introduces FILE_PATHS_MAX = 10_000; watcher state uses filePaths: Map + directoryPaths: Set; on file events performs LRU bump and evicts oldest file entry when cap exceeded; integration tests verify growth/eviction behavior.
Benchmarks & Integration tests
packages/host-service/test/integration/*, packages/host-service/test/*scaling*.test.ts, packages/workspace-fs/src/cache-and-paths-memory.bench.test.ts
Adds/updates benches and integration suites: safety-net sweep scaling, event-driven steady-state commit latency with git-op instrumentation, search-cache heap-delta benchmarks, and pathTypes growth experiments.
Unit tests updated
packages/host-service/test/pull-requests.test.ts, packages/host-service/test/pull-requests-scaling.test.ts, packages/host-service/src/events/event-bus.test.ts
Tests updated to pass gitWatcher stubs and to route sync logic through syncWorkspaceRow for easier mocking while exercising production code.
Documentation / Plan
plans/v2-paths-worktree-perf-findings.md, plans/v2-paths-worktree-perf-fix-plan.md
Adds findings and a multi-fix plan describing issues, proposed fixes (event-driven sync, LRU/TTL cache, pathTypes cap), rollout sequencing, benchmarks, and acceptance criteria.

Sequence Diagram

sequenceDiagram
    participant App
    participant GitWatcher
    participant PullReqMgr as PullRequestRuntimeManager
    participant EventBus
    participant DB
    participant Git

    App->>GitWatcher: start()
    App->>PullReqMgr: start(gitWatcher)
    App->>EventBus: start(gitWatcher)

    GitWatcher->>PullReqMgr: onChanged(workspaceId)
    PullReqMgr->>DB: select workspace row
    PullReqMgr->>Git: rev-parse / symbolic-ref / remote
    PullReqMgr->>DB: update workspace branch/head/upstream (if changed)
    PullReqMgr->>PullReqMgr: refreshProject(projectId)

    Note over PullReqMgr: periodic safety-net (5m) runs syncWorkspaceBranches()
    PullReqMgr->>Git: git ops for all workspaces (sweep)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I watched the git trees, small and spry,

Events now hop — no polling in the sky.
Ten thousand paths kept tidy, old ones shed,
Caches trimmed and steady, memory well-fed.
Hooray — watchers hum and runtime naps instead.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The linked issue #4 requires upgrading tailwind-merge to v3.3.1, but the PR summary and changes focus entirely on host-service and workspace-fs performance optimization without any dependency updates. Verify the correct linked issues are attached. This PR appears to address worktree scaling performance (likely a different issue), not the tailwind-merge upgrade from issue #4.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective: stopping linear scaling of v2 host-service with worktree count through performance improvements.
Out of Scope Changes check ✅ Passed All changes directly support the three documented performance fixes: event-driven GitWatcher integration, LRU cache implementation, and pathTypes capping across host-service and workspace-fs packages.
Description check ✅ Passed The PR description comprehensively covers objectives, measured impact with detailed metrics, test results, and includes thorough companion documentation. Structure aligns well with expected pull request practices.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch v2-paths-worktree-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 3, 2026

Greptile Summary

This PR eliminates three O(N-worktrees) steady-state costs in v2 host-service: the 30 s branch-sync polling loop is replaced by a GitWatcher.onChanged subscription (safety-net sweep drops to 5 min), searchIndexCache gets an LRU+TTL cap at 12 entries, and pathTypes is capped at 10 k with per-event LRU eviction.

  • Two waitFor calls in pull-requests-scaling.integration.test.ts pass { timeout: 10_000 } instead of { timeoutMs: 10_000 }. The option is silently ignored and the effective timeout is the 5 s default, which can cause spurious failures on slow CI runners (flagged inline).
  • getSearchIndex in-flight waiters skip the bumpAndReturnCachedIndex call; the entry is fresh at creation time so premature eviction is unlikely, but the access is unrecorded (flagged inline as P2).

Confidence Score: 3/5

Production code changes are sound; two P1 test bugs mean the CI signal for the critical event-driven path is weaker than it appears.

The core performance fixes (GitWatcher injection, LRU caps) are well-implemented with correct lifecycle management. However, two P1 findings in the integration test silently use a 5 s timeout instead of the intended 10 s, which can cause the headline event-driven steady-state test to give false positives or flaky failures on slow runners, weakening confidence in the test coverage that justifies this change.

packages/host-service/test/integration/pull-requests-scaling.integration.test.ts — two waitFor calls use wrong option key timeout instead of timeoutMs

Important Files Changed

Filename Overview
packages/host-service/src/app.ts Lifts GitWatcher out of EventBus into a shared singleton; correct disposal order (pullRequestRuntime.stop → eventBus.close → gitWatcher.close → db.close).
packages/host-service/src/runtime/pull-requests/pull-requests.ts Replaces 30 s polling timer with GitWatcher subscription; safety-net sweep at 5 min; extracts syncWorkspaceRow helper; stop() correctly clears all three handles.
packages/host-service/src/events/event-bus.ts Accepts injected GitWatcher instead of constructing its own; removes redundant start/close calls; no issues found.
packages/workspace-fs/src/search.ts Adds LRU+TTL cap (12 entries, 30 min) to searchIndexCache; patchSearchIndexesForRoot correctly bumps MRU; in-flight waiters skip the LRU bump (minor P2).
packages/workspace-fs/src/watch.ts Caps pathTypes Map at 10 k entries with LRU eviction in normalizeEvent; delete events still shrink the map; logic is correct.
packages/host-service/test/integration/pull-requests-scaling.integration.test.ts Two waitFor calls pass { timeout } instead of { timeoutMs }, silently using the 5 s default; can cause spurious failures in the event-driven steady-state test on slow CI.
packages/host-service/test/integration/pull-requests-scaling.bench.test.ts New benchmark measuring event-to-DB latency and safety-net sweep cost; correctly uses { timeoutMs } throughout; no issues.
packages/workspace-fs/src/search-cache-eviction.test.ts New LRU eviction tests covering cap, LRU bump, and explicit invalidation; well-structured.
packages/workspace-fs/src/watch-pathtypes-growth.test.ts Integration tests reproducing pathTypes leak and verifying the 10 k cap; extensive real-fs coverage.

Sequence Diagram

sequenceDiagram
    participant FS as .git/ filesystem
    participant GW as GitWatcher
    participant EB as EventBus
    participant PRM as PullRequestRuntimeManager
    participant DB as SQLite DB

    Note over GW: start() — watches all workspace .git/ dirs
    GW-->>EB: onChanged subscription (broadcast git:changed to clients)
    GW-->>PRM: onChanged subscription (event-driven branch sync)

    FS->>GW: fs.watch event (commit / branch switch)
    GW->>GW: debounce 300ms per workspace
    GW->>EB: GitChangedEvent {workspaceId}
    EB->>EB: broadcast git:changed to WebSocket clients

    GW->>PRM: GitChangedEvent {workspaceId}
    PRM->>DB: SELECT workspace WHERE id=workspaceId
    PRM->>PRM: syncWorkspaceRow (git subprocesses)
    PRM->>DB: UPDATE workspaces SET branch/headSha/upstream
    PRM->>PRM: refreshProject (deduplicated by inFlightProjects)

    Note over PRM: Safety-net sweep every 5 min
    PRM->>DB: SELECT all workspaces
    loop each workspace
        PRM->>PRM: syncWorkspaceRow
    end
Loading

Comments Outside Diff (1)

  1. packages/workspace-fs/src/search.ts, line 322-325 (link)

    P2 In-flight waiters don't refresh lastAccessedAt

    When a second getSearchIndex call hits a key already in searchIndexBuilds, it returns await inFlight and never calls bumpAndReturnCachedIndex. The cache entry was just written with lastAccessedAt = Date.now() by the first caller's .then(), so in practice the timestamp is fresh. But conceptually the second caller did access the entry and its access time goes unrecorded, which could cause a spuriously early eviction under a full cache with tight TTL pressure.

    Consider fetching from cache and bumping after the await inFlight resolves, similar to the cache-hit path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/workspace-fs/src/search.ts
    Line: 322-325
    
    Comment:
    **In-flight waiters don't refresh `lastAccessedAt`**
    
    When a second `getSearchIndex` call hits a key already in `searchIndexBuilds`, it returns `await inFlight` and never calls `bumpAndReturnCachedIndex`. The cache entry was just written with `lastAccessedAt = Date.now()` by the first caller's `.then()`, so in practice the timestamp is fresh. But conceptually the second caller did access the entry and its access time goes unrecorded, which could cause a spuriously early eviction under a full cache with tight TTL pressure.
    
    Consider fetching from cache and bumping after the `await inFlight` resolves, similar to the cache-hit path.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/host-service/test/integration/pull-requests-scaling.integration.test.ts:326
**Wrong option key silently drops the custom timeout**

Both `waitFor` calls pass `{ timeout: 10_000 }`, but the locally-defined `waitFor` destructs `{ timeoutMs, pollMs }` — so `timeout` is an unknown key, `timeoutMs` falls back to its 5 s default, and the intended 10 s window is silently ignored. On a slow CI runner the predicate can easily exceed 5 s, causing a spurious failure in the critical event-driven steady-state test.

```suggestion
		await waitFor(() => scenario.gitOpLog.length > 0, { timeoutMs: 10_000 });
```

### Issue 2 of 3
packages/host-service/test/integration/pull-requests-scaling.integration.test.ts:342-344
**Same `timeout` vs `timeoutMs` mismatch on the second call**

Same issue as the call above — `{ timeout: 10_000 }` is ignored; the effective timeout is the 5 s default.

```suggestion
		await waitFor(() => scenario.gitOpLog.length > baselineLogLength, {
			timeoutMs: 10_000,
		});
```

### Issue 3 of 3
packages/workspace-fs/src/search.ts:322-325
**In-flight waiters don't refresh `lastAccessedAt`**

When a second `getSearchIndex` call hits a key already in `searchIndexBuilds`, it returns `await inFlight` and never calls `bumpAndReturnCachedIndex`. The cache entry was just written with `lastAccessedAt = Date.now()` by the first caller's `.then()`, so in practice the timestamp is fresh. But conceptually the second caller did access the entry and its access time goes unrecorded, which could cause a spuriously early eviction under a full cache with tight TTL pressure.

Consider fetching from cache and bumping after the `await inFlight` resolves, similar to the cache-hit path.

Reviews (1): Last reviewed commit: "perf(host-service): event-driven pull-re..." | Re-trigger Greptile

// Wait until the initial sweep AND any startup-related GitWatcher
// events have fully drained — otherwise we'd snapshot mid-flush and
// see leftover ops from another workspace counted as "event-driven".
await waitFor(() => scenario.gitOpLog.length > 0, { timeout: 10_000 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong option key silently drops the custom timeout

Both waitFor calls pass { timeout: 10_000 }, but the locally-defined waitFor destructs { timeoutMs, pollMs } — so timeout is an unknown key, timeoutMs falls back to its 5 s default, and the intended 10 s window is silently ignored. On a slow CI runner the predicate can easily exceed 5 s, causing a spurious failure in the critical event-driven steady-state test.

Suggested change
await waitFor(() => scenario.gitOpLog.length > 0, { timeout: 10_000 });
await waitFor(() => scenario.gitOpLog.length > 0, { timeoutMs: 10_000 });
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
Line: 326

Comment:
**Wrong option key silently drops the custom timeout**

Both `waitFor` calls pass `{ timeout: 10_000 }`, but the locally-defined `waitFor` destructs `{ timeoutMs, pollMs }` — so `timeout` is an unknown key, `timeoutMs` falls back to its 5 s default, and the intended 10 s window is silently ignored. On a slow CI runner the predicate can easily exceed 5 s, causing a spurious failure in the critical event-driven steady-state test.

```suggestion
		await waitFor(() => scenario.gitOpLog.length > 0, { timeoutMs: 10_000 });
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +342 to +344
await waitFor(() => scenario.gitOpLog.length > baselineLogLength, {
timeout: 10_000,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Same timeout vs timeoutMs mismatch on the second call

Same issue as the call above — { timeout: 10_000 } is ignored; the effective timeout is the 5 s default.

Suggested change
await waitFor(() => scenario.gitOpLog.length > baselineLogLength, {
timeout: 10_000,
});
await waitFor(() => scenario.gitOpLog.length > baselineLogLength, {
timeoutMs: 10_000,
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
Line: 342-344

Comment:
**Same `timeout` vs `timeoutMs` mismatch on the second call**

Same issue as the call above — `{ timeout: 10_000 }` is ignored; the effective timeout is the 5 s default.

```suggestion
		await waitFor(() => scenario.gitOpLog.length > baselineLogLength, {
			timeoutMs: 10_000,
		});
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 3, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/workspace-fs/src/watch.ts (1)

478-493: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Evicting directory hints can leave stale descendant search entries.

When a directory ages out of pathTypes, a later delete for that path falls back to isDirectory = false. Downstream, patchSearchIndexesForRoot() only deletes the exact path in that case, so files under the deleted directory can remain searchable until a full rebuild. Please keep directory hints out of the LRU, or add a conservative “unknown type” path that invalidates the root on delete/rename cache misses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workspace-fs/src/watch.ts` around lines 478 - 493, The LRU currently
evicts directory hints (state.pathTypes) which causes deletes to fall back to
isDirectory = false and leaves descendant search entries; update the logic in
the watch handler around stat/state.pathTypes/PATH_TYPES_MAX so directories are
not tracked in the LRU (e.g., keep directories in a separate non-evicting map or
never delete entries where isDirectory === true) or implement a three-state
value (true/false/unknown) in state.pathTypes and on a cache miss or unknown
treat the path as “unknown type” and call patchSearchIndexesForRoot() to
conservatively invalidate the whole root on delete/rename; adjust the
stat/eviction code that currently does state.pathTypes.delete(...), size check
against PATH_TYPES_MAX, and state.pathTypes.set(...) to follow one of these
approaches and ensure downstream callers of state.pathTypes read the new
semantic (unknown -> invalidate root).
🧹 Nitpick comments (2)
packages/host-service/test/integration/pull-requests-scaling.bench.test.ts (1)

36-49: ⚡ Quick win

Promote this proxy helper and remove the any escape hatch.

This instrumentGit() proxy is duplicated in packages/host-service/test/integration/pull-requests-scaling.integration.test.ts, and both copies suppress noExplicitAny. Moving it into a shared test helper with a typed wrapper over raw / revparse / remote will keep the suites in sync and make signature drift easier to catch.

As per coding guidelines "Avoid using any type; prefer explicit type safety in TypeScript." and "If a utility is used 2+ times, promote it to the highest shared parent's directory or top-level components/."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/test/integration/pull-requests-scaling.bench.test.ts`
around lines 36 - 49, Move the duplicated instrumentGit proxy into a shared test
helper and replace the any escape hatch by explicitly typing the wrapped
methods: keep the function name instrumentGit(realGit: SimpleGit, counter:
OpCounter) and implement a typed wrapper for the three targeted members (raw,
revparse, remote) so the proxy returns the exact SimpleGit method signatures
(e.g., raw(...args: string[]): Promise<string> or the actual return types from
SimpleGit) instead of using (target as any); export this helper from a common
test utilities module so both integration and bench tests import it, and remove
the biome-ignore/noExplicitAny comment.
packages/host-service/src/events/event-bus.test.ts (1)

15-17: ⚡ Quick win

Make the GitWatcher unsubscribe observable in this test.

EventBus.close() now owns listener cleanup for the shared watcher, but this stub always returns an anonymous noop, so the new contract is never asserted. Return a named mock(() => {}) here and expect it after eventBus.close().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/src/events/event-bus.test.ts` around lines 15 - 17,
Replace the anonymous noop returned by the GitWatcher stub with a named mock so
the test can assert it was unsubscribed; specifically, change the
gitWatcher.onChanged stub to return a named jest mock (e.g., unsubscribe =
jest.fn()) and wire gitWatcher.onChanged to return that mock, then call
eventBus.close() and add an assertion that the mock unsubscribe was invoked
(expect(unsubscribe).toHaveBeenCalled() or similar) to verify EventBus.close()
cleans up the shared watcher. Reference: GitWatcher, gitWatcher.onChanged,
EventBus.close / eventBus.close.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts`:
- Around line 248-250: The git-change handler can start concurrent
syncOneWorkspace() calls for the same workspace; fix by serializing
per-workspace work using a Map (e.g., workspaceSyncs: Map<string,
Promise<void>>) or a simple per-workspace lock. In the gitWatcher.onChanged
callback (the unsubscribeFromGitWatcher registration) look up the current
promise for event.workspaceId (or use Promise.resolve()), then replace it with
prev.finally(() => syncOneWorkspace(event.workspaceId).catch(err => { /* log */
})) so each call runs after the prior finishes; ensure you store the new promise
back into the map and remove the entry on completion (or on error) to avoid
leaks. Apply the same pattern to the other handler referenced (the block around
syncOneWorkspace at lines 355-367) so all workspace syncs are ordered.

In `@packages/workspace-fs/src/search.ts`:
- Around line 317-319: The cache hit path is returning cached entries
unconditionally; update the logic around searchIndexCache.get(cacheKey) and
bumpAndReturnCachedIndex(cacheKey, cached) to first verify the entry's
TTL/expiry (e.g., stored timestamp or expiry field on the cached object) and
treat expired entries as a miss so the index is rebuilt and re-cached; apply the
same TTL check to the other hit path referenced at lines 327-334 so no stale
index is served past the TTL.

In `@plans/v2-paths-worktree-perf-fix-plan.md`:
- Around line 352-354: Update the acceptance criteria in the plan to match the
actual tests that landed: replace the misleading "idle tick" wording with a
description that
packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
retains the safety-net sweep assertions and adds a single-workspace event-driven
sync case; also clarify that pull-requests-scaling.bench.test.ts now reports "ms
per real change" instead of ~0 ms steady-state cost and that
cache-and-paths-memory.bench.test.ts shows plateau heap deltas (~0.6 MB cache
cap, ~4.3 MB pathTypes cap) independent of input size. Edit the bullet list in
plans/v2-paths-worktree-perf-fix-plan.md to use these exact test file names and
the corrected descriptions so the checklist aligns with the current suite.

---

Outside diff comments:
In `@packages/workspace-fs/src/watch.ts`:
- Around line 478-493: The LRU currently evicts directory hints
(state.pathTypes) which causes deletes to fall back to isDirectory = false and
leaves descendant search entries; update the logic in the watch handler around
stat/state.pathTypes/PATH_TYPES_MAX so directories are not tracked in the LRU
(e.g., keep directories in a separate non-evicting map or never delete entries
where isDirectory === true) or implement a three-state value
(true/false/unknown) in state.pathTypes and on a cache miss or unknown treat the
path as “unknown type” and call patchSearchIndexesForRoot() to conservatively
invalidate the whole root on delete/rename; adjust the stat/eviction code that
currently does state.pathTypes.delete(...), size check against PATH_TYPES_MAX,
and state.pathTypes.set(...) to follow one of these approaches and ensure
downstream callers of state.pathTypes read the new semantic (unknown ->
invalidate root).

---

Nitpick comments:
In `@packages/host-service/src/events/event-bus.test.ts`:
- Around line 15-17: Replace the anonymous noop returned by the GitWatcher stub
with a named mock so the test can assert it was unsubscribed; specifically,
change the gitWatcher.onChanged stub to return a named jest mock (e.g.,
unsubscribe = jest.fn()) and wire gitWatcher.onChanged to return that mock, then
call eventBus.close() and add an assertion that the mock unsubscribe was invoked
(expect(unsubscribe).toHaveBeenCalled() or similar) to verify EventBus.close()
cleans up the shared watcher. Reference: GitWatcher, gitWatcher.onChanged,
EventBus.close / eventBus.close.

In `@packages/host-service/test/integration/pull-requests-scaling.bench.test.ts`:
- Around line 36-49: Move the duplicated instrumentGit proxy into a shared test
helper and replace the any escape hatch by explicitly typing the wrapped
methods: keep the function name instrumentGit(realGit: SimpleGit, counter:
OpCounter) and implement a typed wrapper for the three targeted members (raw,
revparse, remote) so the proxy returns the exact SimpleGit method signatures
(e.g., raw(...args: string[]): Promise<string> or the actual return types from
SimpleGit) instead of using (target as any); export this helper from a common
test utilities module so both integration and bench tests import it, and remove
the biome-ignore/noExplicitAny comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71013585-671a-4a99-a433-244f45ec1272

📥 Commits

Reviewing files that changed from the base of the PR and between e4b38e5 and bd5a773.

📒 Files selected for processing (16)
  • packages/host-service/src/app.ts
  • packages/host-service/src/events/event-bus.test.ts
  • packages/host-service/src/events/event-bus.ts
  • packages/host-service/src/events/index.ts
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/test/integration/pull-requests-scaling.bench.test.ts
  • packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
  • packages/host-service/test/pull-requests-scaling.test.ts
  • packages/host-service/test/pull-requests.test.ts
  • packages/workspace-fs/src/cache-and-paths-memory.bench.test.ts
  • packages/workspace-fs/src/search-cache-eviction.test.ts
  • packages/workspace-fs/src/search.ts
  • packages/workspace-fs/src/watch-pathtypes-growth.test.ts
  • packages/workspace-fs/src/watch.ts
  • plans/v2-paths-worktree-perf-findings.md
  • plans/v2-paths-worktree-perf-fix-plan.md

Comment thread packages/host-service/src/runtime/pull-requests/pull-requests.ts
Comment thread packages/workspace-fs/src/search.ts Outdated
Comment thread plans/v2-paths-worktree-perf-fix-plan.md Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/workspace-fs/src/watch-pathtypes-growth.test.ts">

<violation number="1" location="packages/workspace-fs/src/watch-pathtypes-growth.test.ts:234">
P2: The cap/eviction test can assert too early: waiting for 95% create events does not guarantee `cap-0.tmp` has actually been evicted yet, so this test can fail nondeterministically under slower/coalesced watcher delivery.</violation>
</file>

<file name="packages/workspace-fs/src/search.ts">

<violation number="1" location="packages/workspace-fs/src/search.ts:319">
P2: Idle TTL is bypassed on cache hits, so entries older than 30 minutes are still reused instead of expiring.</violation>
</file>

<file name="packages/host-service/test/integration/pull-requests-scaling.integration.test.ts">

<violation number="1" location="packages/host-service/test/integration/pull-requests-scaling.integration.test.ts:326">
P2: `waitFor` options use `timeout` instead of `timeoutMs`, so this call silently falls back to the 5s default timeout and can make the integration test flaky under slower CI runs.</violation>

<violation number="2" location="packages/host-service/test/integration/pull-requests-scaling.integration.test.ts:343">
P2: This `waitFor` call also uses `timeout` instead of `timeoutMs`, so the intended 10s wait is not applied.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/workspace-fs/src/watch-pathtypes-growth.test.ts Outdated
Comment thread packages/workspace-fs/src/search.ts Outdated
Comment thread packages/host-service/test/integration/pull-requests-scaling.integration.test.ts Outdated
Comment thread packages/host-service/test/integration/pull-requests-scaling.integration.test.ts Outdated
…test timeout

- search.ts: enforce idle TTL on cache hits — previously a hot key was bumped
  forever and only sibling-key misses ran the TTL sweep, so a 30+ min idle
  entry would still be served stale on next access. Now the hit path checks
  freshness and rebuilds when expired.

- pull-requests.ts: serialize syncOneWorkspace per workspaceId via
  Map<workspaceId, Promise>. GitWatcher only debounces 300 ms; two bursts
  far enough apart could run concurrent git reads and let the slower write
  clobber the newer snapshot.

- pull-requests-scaling.integration.test.ts: fix `timeout` → `timeoutMs`
  in two waitFor calls. The wrong key was silently dropped, falling back
  to the 5 s default and risking flake on slow CI.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts`:
- Around line 243-257: The startup sweep and safety-net timer currently call
syncWorkspaceBranches()/syncWorkspaceRow() directly which can race with
watcher-triggered syncs; instead, route those operations through the existing
workspace sync queue by calling enqueueWorkspaceSync(workspaceId) for each
workspace rather than invoking syncWorkspaceBranches or syncWorkspaceRow
directly. Update the initial call site that uses this.syncWorkspaceBranches and
the safetyNetTimer callback to iterate workspaces and call
this.enqueueWorkspaceSync(...) (and replace any direct calls to
this.syncWorkspaceRow(...) at the noted spot) so all syncs are serialized by the
workspaceSyncQueue and deduplicated by inFlightProjects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 50c76720-7367-40b4-a793-a23c71fde220

📥 Commits

Reviewing files that changed from the base of the PR and between bd5a773 and 4af675e.

📒 Files selected for processing (3)
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
  • packages/workspace-fs/src/search.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/host-service/test/integration/pull-requests-scaling.integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workspace-fs/src/search.ts

Comment thread packages/host-service/src/runtime/pull-requests/pull-requests.ts
Replaces the linear promise chain with a "running + rerun pending"
flag so N events for the same workspace collapse into at most one
running sync + one queued rerun. Since each sync reads fresh state,
queuing additional redundant syncs adds no value — it just wastes
git subprocesses.

Bounded under sustained watcher noise (long interactive rebase,
bulk ref churn), where the previous chain could pile up dozens of
sequential no-op syncs.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/host-service/src/runtime/pull-requests/pull-requests.ts (1)

343-358: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route the safety-net/startup sweep through the workspace queue.

Line 348 calls syncWorkspaceRow() directly, which bypasses enqueueWorkspaceSync() ordering. That allows sweep writes to interleave with watcher-triggered syncs for the same workspace and can persist an older snapshot last.

Suggested fix
 private async syncWorkspaceBranches(): Promise<void> {
-	const allWorkspaces = this.db.select().from(workspaces).all();
-	const changedProjectIds = new Set<string>();
-
-	for (const workspace of allWorkspaces) {
-		const projectId = await this.syncWorkspaceRow(workspace);
-		if (projectId) changedProjectIds.add(projectId);
-	}
-
-	// Branch changes use the shared 60s cache rather than bypassing it.
-	// The next refreshEligibleProjects tick will pick up newly-opened PRs;
-	// up to TTL_MS lag on attaching a brand-new external PR is acceptable
-	// and keeps high-churn workspaces from multiplying GraphQL traffic.
-	await Promise.all(
-		[...changedProjectIds].map((projectId) => this.refreshProject(projectId)),
-	);
+	const allWorkspaces = this.db
+		.select({ id: workspaces.id })
+		.from(workspaces)
+		.all();
+
+	for (const workspace of allWorkspaces) {
+		await this.enqueueWorkspaceSync(workspace.id);
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts` around
lines 343 - 358, The startup sweep currently calls syncWorkspaceRow() directly
inside syncWorkspaceBranches(), bypassing enqueueWorkspaceSync() and its
ordering; change the loop to route each workspace through
this.enqueueWorkspaceSync(workspace.id) (awaiting/enqueuing its returned
promise) and collect any returned projectId into changedProjectIds so
enqueueWorkspaceSync's ordering and deduping are respected; keep the later
Promise.all([...changedProjectIds].map(id => this.refreshProject(id)))
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/host-service/src/runtime/pull-requests/pull-requests.ts`:
- Around line 343-358: The startup sweep currently calls syncWorkspaceRow()
directly inside syncWorkspaceBranches(), bypassing enqueueWorkspaceSync() and
its ordering; change the loop to route each workspace through
this.enqueueWorkspaceSync(workspace.id) (awaiting/enqueuing its returned
promise) and collect any returned projectId into changedProjectIds so
enqueueWorkspaceSync's ordering and deduping are respected; keep the later
Promise.all([...changedProjectIds].map(id => this.refreshProject(id)))
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 007ce433-9da2-44d4-8c4c-c77f5c51d0b1

📥 Commits

Reviewing files that changed from the base of the PR and between 4af675e and d7dd8f2.

📒 Files selected for processing (1)
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts

Kitenite added 2 commits May 3, 2026 14:27
…ap test

Splits WatcherState.pathTypes into filePaths (LRU-capped at 10k) and
directoryPaths (uncapped Set). Pre-fix, the unified Map could LRU-evict
a directory hint, after which a delete event for that directory fell
back to isDirectory=false and patchSearchIndexesForRoot only pruned
the exact path — leaving descendant search-index entries stale until
the next full rebuild. Directory count per worktree is bounded by repo
structure (O(100s) even for huge repos), so tracking them uncapped is
fine; only the file-path stream grows unboundedly.

Also fixes the cap-eviction test which was polling on a 95% event-count
threshold (10_000 cap × 95% = 9,690 events, which can land before
eviction triggers and stall under coalesced delivery). Now polls on
the actual eviction outcome — `pathTypes.has(cap-0.tmp) === false` —
and asserts cap on filePaths.size directly via a new getFilePathsSize
helper. Bench predicate is similarly capped at min(target, FILE_PATHS_MAX)
to avoid spinning the deadline once size plateaus.
The serialization queue added previously only covered the watcher-driven
path; `syncWorkspaceBranches` (initial startup sweep + 5-min safety net)
still called `syncWorkspaceRow` directly, so it could race a concurrent
watcher-triggered sync for the same workspace and clobber newer state.

The sweep now iterates ids and routes each through enqueueWorkspaceSync,
which coalesces — if a watcher sync is already running for a workspace,
the sweep just flips rerunPending and awaits the running promise.
Sequential per-workspace iteration matches the original sweep's
git-subprocess concurrency profile.

Test mocks override syncOneWorkspace to bypass the drizzle .where()
chain, since the sweep now performs a per-workspace row lookup that
doesn't compose cleanly with the existing chained mock structure.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/workspace-fs/src/watch-pathtypes-growth.test.ts (2)

232-233: ⚡ Quick win

Avoid hard-coding the cap in the test.

PATH_TYPES_MAX = 10_000 duplicates the production knob from watch.ts. If that cap changes, this test will drift silently. Export the cap, or expose a test-only accessor, so the assertion stays coupled to the real limit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workspace-fs/src/watch-pathtypes-growth.test.ts` around lines 232 -
233, The test hard-codes PATH_TYPES_MAX (10_000) causing it to diverge from
production; instead, expose the real cap from the module that enforces it
(watch.ts) and import it in the test (e.g., export a constant like
PATH_TYPES_MAX or a test-only accessor such as getPathTypesLimit) so the test
uses that exported symbol rather than a literal; update the test to reference
the exported name (e.g., PATH_TYPES_MAX from watch.ts) and compute total =
exportedLimit + 200 to keep the assertion coupled to the real limit.

110-110: Replace event-count polling with state-based waits for test stability.

These tests poll event counts (events.length, events.filter().length, createCount), but @parcel/watcher does not guarantee consistent event delivery across platforms—backends coalesce, batch, and throttle differently. The cap test in this same file (lines 243–246) already demonstrates the better pattern: polling on actual state via getPathTypes() / getFilePathsSize(). Use the same approach at lines 110, 142, 150–152, 182–184, 193–195, 208–210, and 290 to eliminate backend sensitivity and improve reliability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workspace-fs/src/watch-pathtypes-growth.test.ts` at line 110, Tests
currently poll event arrays (waitForCondition(() => events.length >= ...),
events.filter(...), createCount) which is flaky across watcher backends; replace
those waits with state-based waits against the filesystem helpers used
elsewhere. Locate uses of waitForCondition that reference events, events.filter,
or createCount and instead call the existing getPathTypes() or
getFilePathsSize() helpers inside the wait predicate (e.g., waitForCondition(()
=> getPathTypes(root).length >= expected) or waitForCondition(() =>
getFilePathsSize(root) >= expected)) so the tests assert actual FS state rather
than event delivery. Also remove/stop relying on the events array for test
success assertions and adapt assertions to read from
getPathTypes()/getFilePathsSize() where needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/workspace-fs/src/watch-pathtypes-growth.test.ts`:
- Around line 34-40: The afterEach cleanup currently only removes tempRoots and
can leave live watchers running if a test throws; modify the tests so watcher
subscriptions/manager handles are always cleaned up on failure by either
tracking watcher unsubscribe functions (or manager instances) in a shared array
and calling their cleanup inside afterEach alongside the tempRoots fs.rm logic,
or by wrapping each test body that calls waitForCondition() / startWatcher() /
createWatcherManager() in try/finally and calling the corresponding
unsubscribe/close method in the finally block; update references to tempRoots,
afterEach, waitForCondition, and any watcher-related symbols (e.g.,
watcher.unsubscribe, manager.close, stopWatching) across the test blocks
mentioned (lines ~34-297) so no live watcher remains when fs.rm runs.

In `@packages/workspace-fs/src/watch.ts`:
- Around line 492-505: normalizeEvent() currently awaits stat(absolutePath)
before updating the LRU Maps, so concurrent calls from flushPendingEvents()
(Promise.all) can reorder mutations and break LRU eviction; fix by moving the
state mutations to occur before the async stat (or otherwise serializing
mutations), i.e., reserve/update state.filePaths and state.directoryPaths and
perform the LRU eviction (using FILE_PATHS_MAX and deleting oldestKey)
synchronously in normalizeEvent() prior to awaiting stat(absolutePath), or
implement a simple mutation queue/lock so state mutations for normalizeEvent()
run in watcher-event order instead of stat-completion order.

---

Nitpick comments:
In `@packages/workspace-fs/src/watch-pathtypes-growth.test.ts`:
- Around line 232-233: The test hard-codes PATH_TYPES_MAX (10_000) causing it to
diverge from production; instead, expose the real cap from the module that
enforces it (watch.ts) and import it in the test (e.g., export a constant like
PATH_TYPES_MAX or a test-only accessor such as getPathTypesLimit) so the test
uses that exported symbol rather than a literal; update the test to reference
the exported name (e.g., PATH_TYPES_MAX from watch.ts) and compute total =
exportedLimit + 200 to keep the assertion coupled to the real limit.
- Line 110: Tests currently poll event arrays (waitForCondition(() =>
events.length >= ...), events.filter(...), createCount) which is flaky across
watcher backends; replace those waits with state-based waits against the
filesystem helpers used elsewhere. Locate uses of waitForCondition that
reference events, events.filter, or createCount and instead call the existing
getPathTypes() or getFilePathsSize() helpers inside the wait predicate (e.g.,
waitForCondition(() => getPathTypes(root).length >= expected) or
waitForCondition(() => getFilePathsSize(root) >= expected)) so the tests assert
actual FS state rather than event delivery. Also remove/stop relying on the
events array for test success assertions and adapt assertions to read from
getPathTypes()/getFilePathsSize() where needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 466d0053-26cf-4f27-8fa3-a56f38ee3081

📥 Commits

Reviewing files that changed from the base of the PR and between d7dd8f2 and 7be485d.

📒 Files selected for processing (3)
  • packages/workspace-fs/src/cache-and-paths-memory.bench.test.ts
  • packages/workspace-fs/src/watch-pathtypes-growth.test.ts
  • packages/workspace-fs/src/watch.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workspace-fs/src/cache-and-paths-memory.bench.test.ts

Comment thread packages/workspace-fs/src/watch-pathtypes-growth.test.ts
Comment thread packages/workspace-fs/src/watch.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/host-service/test/pull-requests.test.ts (1)

154-162: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert the warning payload, not just that a warning happened.

toHaveBeenCalled() will pass for any console.warn, so an unrelated warning could satisfy the test even if the HEAD-failure path regressed. Check the message or error object to pin the assertion to the expected failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/test/pull-requests.test.ts` around lines 154 - 162, The
test currently only checks that console.warn was called which can be triggered
by unrelated warnings; update the assertion to verify the actual warning payload
from the HEAD-failure path. After calling manager.syncWorkspaceBranches(),
assert that the warnSpy (mocking console.warn) was called with the expected
message or error object (e.g., using warnSpy.toHaveBeenCalledWith(...) or
inspecting warnSpy.mock.calls[0][0]) that indicates the HEAD failure, while
keeping the existing checks for setMock and refreshProjectMock unchanged;
reference the syncWorkspaceBranches invocation and warnSpy/console.warn to
locate where to update the assertion.
🧹 Nitpick comments (1)
packages/host-service/test/pull-requests.test.ts (1)

60-75: ⚡ Quick win

Keep this test on the production sync path.

Overriding syncOneWorkspace here hardcodes the row and bypasses the new queue/lookup layer, so the test no longer catches regressions in workspace selection, coalescing, or serialization. Prefer a stub that still lets the real syncOneWorkspace path run, or at least assert the workspace ID passed into it.

Also applies to: 138-153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/test/pull-requests.test.ts` around lines 60 - 75, The
test currently replaces syncOneWorkspace with a hardcoded implementation that
calls syncWorkspaceRow directly and bypasses the real queue/lookup/selection
logic; instead, capture the original syncOneWorkspace, replace it with a thin
wrapper that asserts the workspace ID passed in (or records it) and then calls
the original implementation so the production sync path still runs; reference
syncOneWorkspace and syncWorkspaceRow (and the alternative occurrence around the
other block) and apply the same wrapper pattern there rather than hardcoding the
row.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/host-service/test/pull-requests.test.ts`:
- Around line 154-162: The test currently only checks that console.warn was
called which can be triggered by unrelated warnings; update the assertion to
verify the actual warning payload from the HEAD-failure path. After calling
manager.syncWorkspaceBranches(), assert that the warnSpy (mocking console.warn)
was called with the expected message or error object (e.g., using
warnSpy.toHaveBeenCalledWith(...) or inspecting warnSpy.mock.calls[0][0]) that
indicates the HEAD failure, while keeping the existing checks for setMock and
refreshProjectMock unchanged; reference the syncWorkspaceBranches invocation and
warnSpy/console.warn to locate where to update the assertion.

---

Nitpick comments:
In `@packages/host-service/test/pull-requests.test.ts`:
- Around line 60-75: The test currently replaces syncOneWorkspace with a
hardcoded implementation that calls syncWorkspaceRow directly and bypasses the
real queue/lookup/selection logic; instead, capture the original
syncOneWorkspace, replace it with a thin wrapper that asserts the workspace ID
passed in (or records it) and then calls the original implementation so the
production sync path still runs; reference syncOneWorkspace and syncWorkspaceRow
(and the alternative occurrence around the other block) and apply the same
wrapper pattern there rather than hardcoding the row.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8b254526-e5db-4560-8dab-9d867a1dcab3

📥 Commits

Reviewing files that changed from the base of the PR and between 7be485d and 331c180.

📒 Files selected for processing (3)
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts
  • packages/host-service/test/pull-requests-scaling.test.ts
  • packages/host-service/test/pull-requests.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/host-service/src/runtime/pull-requests/pull-requests.ts

Kitenite added 3 commits May 3, 2026 15:21
- watch.ts: serialize normalizeEvent calls in flushPendingEvents — replaces
  Promise.all with a sequential for-of loop so LRU mutations land in event
  order, not stat-completion order. Net code roughly unchanged but removes
  the concurrency hazard coderabbit flagged.

- watch-pathtypes-growth.test.ts: consolidate manager cleanup into afterEach.
  Tests register managers via createManager() and stop calling unsubscribe()
  + manager.close() inline. afterEach closes them all even if a test throws.
  Net code reduction (-16 lines).

- pull-requests.test.ts: tighten warn assertion to match the actual
  "Failed to sync workspace" prefix instead of accepting any console.warn.

- v2-paths-worktree-perf-fix-plan.md: align acceptance criteria wording with
  the tests that actually landed.
…lper

`evictStaleSearchIndexEntries` is redundant: per-hit TTL check on
getSearchIndex line 299-307 already discards stale entries on access,
and the hard LRU cap of 12 bounds memory regardless of TTL behavior.
The build-path sweep over all entries was duplicated work that did
nothing the LRU eviction wasn't already doing.

`bumpAndReturnCachedIndex` had one caller and was 4 lines of body —
inlined directly into the hit path. Net -23 lines.
# Conflicts:
#	packages/host-service/src/runtime/pull-requests/pull-requests.ts
CI typecheck failed because workspace-fs had a hand-rolled
src/bun-test.d.ts shim with a minimal `expect` (only `toContain` /
`toEqual` / `toHaveLength` / `toBeNull` / `toBeTruthy`) that shadowed
the real bun-types definitions. Adding bun-types as a devDependency
and dropping the shim restores the full matcher surface.

CI tests OOM'd on @superset/workspace-fs#test (exit 137). The
cache-and-paths-memory bench creates 130 worktrees × 200 files +
heap snapshots and was being picked up by default `bun test` because
of its `.bench.test.ts` suffix. Renamed both bench files to
`.bench.ts` (off the auto-discovery pattern) and added explicit
`bun run bench` scripts so they're still runnable on demand.

Also tightened search-cache-eviction.test.ts array typings: previous
`unknown[]` was fine under the shim's permissive `expect` but doesn't
typecheck against the real signature. Now uses
`Awaited<ReturnType<typeof getSearchIndex>>[]` with explicit guards
for noUncheckedIndexedAccess.
…nits

CI host-service#test was OOMing (exit 137) because the integration
test created 4 scenarios with simple-git + WorkspaceFilesystemManager +
GitWatcher per scenario (15 worktrees + 15 parcel-watcher subscriptions
total). Two of those scenarios just re-asserted what the mock-based
unit test in test/pull-requests-scaling.test.ts already pins —
linearity of git-subprocess count and "safety-net walks all N".

Removes the duplicative integration scaling cases. Keeps only the
event-driven scenario, which is the unique integration coverage
(verifies a real `git commit` in one workspace triggers exactly one
single-workspace sync, with the others staying quiet). Reduced from
5 to 3 worktrees — enough to prove "only the target was touched".

Net: -142 lines, ~80% fewer worktrees spawned per test file run.
@Kitenite Kitenite merged commit fcde3aa into main May 4, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant