fix: drop stale repos fetches so deleted projects don't reappear (#7020)#7024
Conversation
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.
|
Ready to review this PR? Stage has broken it down into 3 individual chapters for you:
Chapters generated by Stage for commit a4e583e on Jul 1, 2026 1:52pm UTC. |
…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]>
Co-authored-by: Orca <[email protected]>
|
Warning Review limit reached
Next review available in: 51 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
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. Comment |
|
Took this over to finish the pipeline (issue #7020). Summary of what I did on top of @AnddyAgudelo's original fix:
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. |
|
Merged (squash) — thanks @AnddyAgudelo! Clean root-cause analysis and a solid deterministic repro; it passed the full bar (security re-screen, reproduction on |
|
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! |
|
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! 🐋 |
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'srepos.onChangedhandler fires one unsequencedfetchRepos()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.
fetchReposclaims a monotonicreposFetchGeneration(held in slice state, mirroring the existingcommitMessageGenerationRequestSeq/pullRequestGenerationRequestSeqconvention) before awaiting, and drops its ownset()if a newer fetch has superseded it while it was in flight. Only the latest fetch — the one started by the finalrepos:changed, which reads the final persisted state — applies. The generation is claimed synchronously before theawait, so even a superseding fetch that later rejects still blocks the older stale fetch from resurrecting a removed repo.Evidence
repos-stale-fetch.test.tsreproduces the race deterministically (a stale fetch that reads pre-removal state but resolves last):main(bug present): both cases fail — the stale fetch's result wins and the removed repo reappears.Test Files 1 passed · Tests 2 passed, and the full repo-slice suite is green (24 passed).pnpm run typecheckpasses across all three projects;oxlintclean 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
removeProject, or persistence path changes.reposFetchGenerationlives in slice state and no component subscribes to it, so it triggers no re-renders.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 guardedfetchReposForAllHostswith the same counter, but a shared counter let an unrelatedfetchReposbump 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 atmain'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*RequestSeqconvention, and scoped the guard tofetchRepos.