Fix duplicate review sessions on in-flight crow:auto kickoff#407
Merged
Conversation
After #405 made `createReviewSession` non-blocking on the main actor, the IssueTracker watcher can tick during the ~10s clone window and enqueue a second kickoff for the same PR — the cross-reference field it gates on (`ReviewRequest.reviewSessionID`) is populated lazily on the next refresh, so it stays `nil` until after the session has already been appended. Stop trusting the lagging cross-reference. Consult the authoritative `appState.sessions` (filtered to active reviews) via a single shared `existingReviewSession(forPRURL:)` helper. Layered checks: - Watcher guard in AppDelegate skips kickoff when a session for the PR URL already exists in `appState`, regardless of the `request` cross-ref. - Universal backstop in `SessionService.createReviewSession` early-returns the existing session ID. Safe because `enqueueReviewKickoff` is already serialized — Task B observes Task A's appended row. - Single + batch Start Review buttons in `ReviewBoardView` use the same helper, so the UI flips to "Go to Session" the instant the row appears instead of lagging by an IssueTracker tick. Also extract the PR-URL parser inlined in `createReviewSession` into `Session.parseReviewPR(url:)` so all sites share one implementation. Closes #406 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: 8B3BF03E-DEA0-4C05-B65F-D83BC2CF60CB
dhilgaertner
approved these changes
Jun 2, 2026
Contributor
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None.
Security Review
Strengths:
Session.parseReviewPR(url:)is pure string parsing with no injection surface; it preserves the exact behavior of the inlined parser it replaces (split(separator: "/"),count >= 5, trailing-segmentIntcast).- No new untrusted input paths — PR URLs originate from the GitHub-backed
ReviewRequest/SessionLinkdata that already flows through the app. - The new
existingReviewSession(forPRURL:)is correctly@MainActor-isolated (matchesAppState), and every caller (watcher, review-board buttons,SessionService) is already on the main actor, so there's no data-race introduced.
Concerns:
- None.
Code Quality
- Dedup is genuinely authoritative, not racy. The serial
reviewKickoffTailqueue (AppDelegate.swift:173-187) awaits the previous tail, so the secondcreateReviewSessiononly runs after the first has fully returned — and the session +.prlink are appended synchronously on the main actor atSessionService.swift:1337-1340after the detached clone await. The backstop atSessionService.swift:1255therefore always sees the prior session. Sound. - Watcher guard change is the right shape.
guard (request.reviewSessionID == nil && existingByPR == nil) || shaAdvancedis strictly more restrictive only on the fresh-kickoff path and leaves theshaAdvancedre-kick path (force-push / round-2) untouched, including the stale-session teardown atAppDelegate.swift:637. The A-path completion case still kicks off correctly sinceexistingReviewSessionexcludes completed/archived sessions (reviewSessionsfilter atAppState.swift:380-382). - Refactor removes real duplication by sharing one parser across
SessionServiceand the dedup helpers, and the.pr-linkType + exact-URL match is consistent across all three call sites (the link is stored from the sameprURLthe watcher/buttons pass in). - Test coverage is thorough — the new suites cover nil/match/completed/archived/non-PR-linkType/duplicate cases for the lookup and owner/repo/number/malformed cases for the parser. All 10 new tests pass locally (
swift test --package-path Packages/CrowCore).
Minor, non-blocking observations (no action needed): existingReviewSession is O(sessions × links) and recomputed each watcher iteration, but both sets are tiny; and exact-string URL matching is fine here because the same URL source feeds both the stored link and every lookup.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 0 Green] findings. Correct, well-isolated, well-tested fix for the in-flight kickoff race.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ReviewRequest.reviewSessionIDcross-ref and consultappState.sessionsdirectly via a newAppState.existingReviewSession(forPRURL:)helper.createReviewSessionearly-return backstop, and both Start Review buttons inReviewBoardView.Session.parseReviewPR(url:)so all sites share one parser.Why
After #405 moved
createReviewSession's git work intoTask.detached, the main actor stays responsive during the ~10s clone window. The IssueTracker watcher can now tick during that window — and becauserequest.reviewSessionIDis populated lazily on the next refresh, the watcher's existing guard sees stalenil, enqueues a second kickoff, andcreateReviewSession(with no dedup of its own) creates a duplicate session.The same lag was also why the per-row Start Review button stayed clickable for several seconds after a session had been created.
Test plan
arch -arm64 swift test --package-path Packages/CrowCore— 205 tests pass (includes the newexistingReviewSessionandparseReviewPRtest files)arch -arm64 swift test(root + every package) — all suites pass except a pre-existing flakyretryReadinessEmitsTimedOutWhenSentinelMissingtmux timing test in CrowTerminal that's unrelated to this changeswift build --arch arm64cleanautoReviewReposconfigured, open acrow:autoPR, confirm exactly onereview-<repo>-<num>session appears even when the IssueTracker ticks multiple times during the clone; confirm the Start Review button flips to "Go to Session" on the same tick the session appears; force-push the PR to confirm the SHA-advanced re-kick path still works.Closes #406
🤖 Generated with Claude Code