Skip to content

fix: drop stale repos fetches so deleted projects don't reappear (#7020)#7024

Merged
nwparker merged 5 commits into
stablyai:mainfrom
AnddyAgudelo:fix/repos-changed-refetch-race
Jul 3, 2026
Merged

fix: drop stale repos fetches so deleted projects don't reappear (#7020)#7024
nwparker merged 5 commits into
stablyai:mainfrom
AnddyAgudelo:fix/repos-changed-refetch-race

Conversation

@AnddyAgudelo

@AnddyAgudelo AnddyAgudelo commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #7020

Description

Deleting a Project Group with "Remove contained projects" could leave the removed projects as stale, unusable rows in the sidebar until an app restart.

Root cause. The group deletion and each contained-project removal each emit their own repos:changed, and the renderer's repos.onChanged handler fires one unsequenced fetchRepos() per event. Those fetches race: an earlier fetch that read main-process state before the removals persisted can resolve last and overwrite the newer result, reintroducing the just-deleted repos into the in-memory store. A restart hides it only because startup reloads the final persisted state.

Fix. fetchRepos claims a monotonic reposFetchGeneration (held in slice state, mirroring the existing commitMessageGenerationRequestSeq / pullRequestGenerationRequestSeq convention) before awaiting, and drops its own set() if a newer fetch has superseded it while it was in flight. Only the latest fetch — the one started by the final repos:changed, which reads the final persisted state — applies. The generation is claimed synchronously before the await, so even a superseding fetch that later rejects still blocks the older stale fetch from resurrecting a removed repo.

Evidence

repos-stale-fetch.test.ts reproduces the race deterministically (a stale fetch that reads pre-removal state but resolves last):

  • On main (bug present): both cases fail — the stale fetch's result wins and the removed repo reappears.
    - Expected  []
    + Received  [ { id: "local-repo" }, { id: "remote-repo" } ]
    Test Files  1 failed (1) · Tests  2 failed (2)
    
  • On this branch (fixed): Test Files 1 passed · Tests 2 passed, and the full repo-slice suite is green (24 passed).
  • pnpm run typecheck passes across all three projects; oxlint clean on the touched files.

ELI5

Deleting a folder-of-projects sends several "the project list changed!" pings. The app reloads the list once per ping, but the reloads can finish out of order — an old reload that still remembers the deleted projects can land last and paste them back. We now stamp each reload with an increasing number and ignore any reload that finishes after a newer one started, so the newest (correct, post-deletion) list always wins.

Trade-offs / regressions

  • No regressions. The guard only ever drops a result that a newer fetch has already superseded; the newest fetch always applies. No public API, removeProject, or persistence path changes. reposFetchGeneration lives in slice state and no component subscribes to it, so it triggers no re-renders.
  • Scope. The guard is deliberately limited to fetchRepos (the exact path in [Bug]: Deleting a project group can leave removed contained projects as stale sidebar rows until restart #7020's repro). The original revision also guarded fetchReposForAllHosts with the same counter, but a shared counter let an unrelated fetchRepos bump it and abort an in-flight all-host load, dropping every host's repos — so that guard was removed. The runtime-environment-active all-host path is therefore left at main's behavior (no new regression, and not part of the reported repro). Coalesce repos:changed refetch so stale fetches can't leave removed projects as ghost rows (#7020) #7083 (@xianjianlf2) proposed a coalescing-runner approach that would also cover that path and de-dupe the burst; credit to that framing for the broader-path analysis. This PR takes the surgical, store-level fix for the reported bug; the all-host path can follow up.

Credit

Original fix and repro by @AnddyAgudelo. Follow-up polish: isolated the regression test into repos-stale-fetch.test.ts (+ a superseding-reject case), moved the counter into slice state to match the repo's *RequestSeq convention, and scoped the guard to fetchRepos.


Open in Stage

Deleting a project group with "Remove contained projects" could leave the
removed projects as stale, unusable sidebar rows until an app restart (stablyai#7020).
`repos:changed` fires once per removal and the renderer starts an unsequenced
repos fetch per event; an earlier fetch that read pre-removal state could
resolve last and overwrite the newer result, reintroducing the deleted repos.

Guard fetchRepos and fetchReposForAllHosts with a monotonic token so a fetch
drops its own result once a newer repos fetch has superseded it — only the
latest fetch, which reads the final persisted state, applies. Add a regression
test that a stale fetch resolving after a newer one can't resurrect a removed
repo.
@stage-review

stage-review Bot commented Jul 1, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Introduce monotonic token for repo fetch sequencing
2 Guard repository fetch operations against stale results
3 Verify stale fetch rejection with regression test
Open in Stage

Chapters generated by Stage for commit a4e583e on Jul 1, 2026 1:52pm UTC.

nwparker and others added 4 commits July 3, 2026 12:58
…tchRepos

Move the stablyai#7020 regression test out of repos.test.ts into a focused
repos-stale-fetch.test.ts and add a reject-path case (a superseding fetch
that later rejects must still block the older stale fetch). Scope the
monotonic guard to fetchRepos only: the original shared-counter guard on
fetchReposForAllHosts let an unrelated fetchRepos bump the counter and
abort an in-flight all-host load, dropping every host's repos.

Co-authored-by: Orca <[email protected]>
…iomatic

Move the stablyai#7020 regression test out of repos.test.ts into a focused
repos-stale-fetch.test.ts and add a reject-path case (a superseding fetch
that later rejects must still block the older stale fetch).

Keep the monotonic generation in slice state (like commitMessageGenerationRequestSeq
/ pullRequestGenerationRequestSeq) instead of a module-level global, so each store
owns its own counter. Scope the guard to fetchRepos only: the original PR's shared
guard on fetchReposForAllHosts let an unrelated fetchRepos bump the counter and
abort an in-flight all-host load, dropping every host's repos.

Co-authored-by: Orca <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@nwparker, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6ebef42a-cb33-4faa-89b9-fca39f9a8917

📥 Commits

Reviewing files that changed from the base of the PR and between 02dfb4b and 3942221.

📒 Files selected for processing (3)
  • src/main/browser/cdp-ws-proxy.test.ts
  • src/renderer/src/store/slices/repos-stale-fetch.test.ts
  • src/renderer/src/store/slices/repos.ts

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.

@nwparker

nwparker commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Took this over to finish the pipeline (issue #7020). Summary of what I did on top of @AnddyAgudelo's original fix:

  • Re-screened the diff — pure renderer store/race logic, no deps/CI/network/obfuscation. Clean.
  • Reproduced the race deterministically. New repos-stale-fetch.test.ts fails on main (the stale fetch resurrects the removed repo) and passes here:
    main:   Tests  2 failed (2)   // Received [local-repo, remote-repo], expected []
    branch: Tests  2 passed (2)   // full repo-slice suite: 24 passed
    
  • Hardened the implementation: moved the monotonic generation into slice state (matching the existing commitMessageGenerationRequestSeq / pullRequestGenerationRequestSeq convention) instead of a module global, and added a superseding-reject case (a newer fetch that later throws must still block the older stale one, since the generation is claimed synchronously before the await).
  • Scoped the guard to fetchRepos. The original also guarded fetchReposForAllHosts with the same counter, but a shared counter let an unrelated fetchRepos bump it and abort an in-flight all-host load (dropping every host's repos). Removing that guard leaves the all-host path at main's behavior — no new regression.
  • pnpm run typecheck (all 3 projects) + oxlint clean.

Competing PR #7083 (@xianjianlf2) targets the same issue with a coalescing-runner approach that also covers the all-host path; credit to that framing in the PR body. This surgical store-level fix is the one being merged for the reported bug.

@nwparker
nwparker merged commit 97c30c5 into stablyai:main Jul 3, 2026
@nwparker

nwparker commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Merged (squash) — thanks @AnddyAgudelo! Clean root-cause analysis and a solid deterministic repro; it passed the full bar (security re-screen, reproduction on main, typecheck across all projects, lint, and multiple review rounds). Appreciate the contribution. 🙌

@nwparker

nwparker commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Thanks, @AnddyAgudelo. Deleted projects reappearing as ghost rows is such an unsettling little bug, and your generation guard is a clean, tightly scoped way to close the race. Appreciate you chasing it down — merged!

@nwparker

nwparker commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Thank you, Anddy! 🙏 Out-of-order refetches resurrecting deleted projects is a classic async gremlin, and the monotonic-generation guard is the textbook-right fix. Clean, well-tested, easy to reason about. Thanks for contributing! 🐋

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.

[Bug]: Deleting a project group can leave removed contained projects as stale sidebar rows until restart

3 participants