fix(automations,pulls): re-read review state from disk before gating
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
AI review automations gate on persisted review history and dismissal watermarks, but the Tauri plugin-store caches its contents per process and only loads at launch. A second GitDesktop window/instance — or a restarted dev session — therefore gated against a launch-time snapshot, concluded "never reviewed", and re-ran a review another instance had already delivered. This adds an opt-in fresh read that reloads the store from disk at the gates, and flushes dismissal writes immediately so a fresh read can't race past them.
read(repo, opts) helper in src/lib/pulls/reviews-history.ts that reloads the store from disk before merging records when fresh is set, running the reload inside the existing serialize queue so it's ordered against in-flight mutations. getLatestReview and listReviews both take the new opts?: { fresh?: boolean } and route through it.reloadRaw() to src/lib/automations/dismissals.ts, guarding the case where reload() rejects with a raw io error on a store file that doesn't exist yet (unlike load()), and falling back to in-memory state on any failure — mirroring the guard already in reviews-history.ts. getDismissedHead gains the same fresh option.setDismissedHead and clearDismissedHead in dismissals.ts await store.save() instead of relying on autoSave's ~100ms debounce; without the explicit flush, a fresh gate read inside that window would discard a just-written dismissal (or undo a clear, re-blocking the re-run it was meant to unblock).fresh is opt-in, so non-gate readers keep the cached path.src/lib/automations/runner.ts passes { fresh: true } to the pr-sync gate's listReviews and getDismissedHead calls, and to the pr-open gate's getLatestReview and getDismissedHead calls, so both post-delivery authorities see another instance's just-written record.src/lib/automations/sync.ts does the same in prOpenEligible, for both the prior-review check and the dismissed-head check across the general and security modes.changelog.d/fixed-duplicate-review-cross-instance.md describing the fix for users. No README, marketing-site, or in-app guide changes: this corrects existing behavior without altering any documented surface or claim.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
b126b1bView logs
Originally posted by: theBGuy
Context for reviewers — deliberate calls with their evidence, numbered for reference.
What this PR is. Fixes cross-instance duplicate AI reviews (live-caught 2026-08-03: a second app instance re-reviewed an already-reviewed PR with no push). Root cause: the automation claim is an OS-atomic lock that dedupes concurrent runs, and after delivery authority passes to the pr-reviews history record — but every gate that reads that record was reading the tauri-plugin-store per-process cache loaded at launch (writes reload from disk first; reads never did). A second instance — or an HMR-restarted dev instance, since the in-memory dedup sets deliberately reset on restart — saw stale "never reviewed" state and fired.
The fix is fresh reads at the gates, opt-in.
getLatestReview/listReviews/getDismissedHeadgain an optional trailing{ fresh?: boolean }; the fresh path re-reads from disk (inside reviews-history's existing serialize queue, so it's ordered with in-flight mutations). Exactly six call sites pass it — the runner's pr-sync/pr-open gates andprOpenEligible. UI callers (prior-context.ts,pulls/queries.ts) deliberately stay on the cached read; only automation decisions need claim-fresh state.An automations "leader election" was built, adversarially reviewed, and deliberately descoped before this PR. The review found a single per-repo lease is the wrong design (it can hand leadership to an instance that cannot fire local-PR automations; effect-based claimants under-renew because React Query's structural sharing keeps quiet-tick references stable; restart minted a ≤4-minute blackout). Correctness doesn't need it: a delivered run keeps its claim for 30 minutes, and these fresh reads cover everything beyond. The leader returns later as a capability-scoped redesign (backlogged with the review's constraints). Don't flag the absence of coordination as a gap — it's the recorded decision.
The dismissals write paths gain a forced
store.save()— this pairs with the fresh reads.setDismissedHead/clearDismissedHeadpreviously ended atstore.set, relying on autoSave's ~100ms debounce; a fresh reload inside that window would discard the unsaved dismissal (so a cancelled head would re-fire after relaunch — the exact thing the store prevents). All three writers of that store now flush before returning (the third,identityKeyFor's legacy fold, already did). reviews-history needs no such change — itswriteAllalready force-saves.prOpenEligiblealso serves the in-app Mark-ready trigger, so that click now pays one store reload. Deliberate — freshness is correct there too, and it's the only guard covering manual panel reviews (they never take a claim).Disclosures:
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo genuinely exploitable security vulnerabilities in these changes — the diff only adds opt-in disk re-reads and forced flushes to local, per-user app-data stores used for automation dedup gating, with no new untrusted input, sink, auth boundary, or secret handling.
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedFixes cross-instance duplicate AI reviews by adding an opt-in
freshdisk re-read to the three store readers the automation gates consult, plus a forced flush on the two dismissal writers. The core approach is sound and the six gate call sites are exactly the right ones (I verified the full caller set — the only other readers arepulls/queries.ts:73andai/prior-context.ts:49); one race introduced on the dismissals side should be fixed before merge.Recorded decisions I'm not re-raising: the descoped leader election (#3), UI callers staying cached (#2),
prOpenEligible's extra reload on Mark-ready (#5), and the absence of a live two-instance run (#6). Docs: the changelog fragment is present and matcheschangelog.d/README.md's format (leading-, 2-space continuations,fixed-category); README/site/help are untouched, consistent with the recorded "internal reliability fix" call — I greppedsrc/features/help/content.tsand found no claim this change makes stale.Correctness
src/lib/automations/dismissals.ts:38-45(reloadRaw),:73-83(getDismissedHead),:86-100(setDismissedHead),:109-123(clearDismissedHead): the new reload runs outside any serialization while both writers do an unserializedstore.get→store.set→store.save.store.setonly mutates the Rust-side map (plugin:store|set), andreloadreplaces that map from the file — so a reload landing between the awaitedsetand the awaitedsavediscards the write, and the followingsave()then persists the reverted state. Concrete path, entirely within this diff: on cancel,runner.ts:471/:533calldismissOnCancel()fire-and-forget (void setDismissedHead(...).catch(...),runner.ts:431-440) and immediatelycontinueto the next matching action, whose gate awaitsgetDismissedHead(..., { fresh: true })→reloadRaw()(runner.ts:289-295/:321-327); both flows are several IPC hops with no ordering between them. The same race fires whenever a cancel coincides with a poll tick, since this one store file serves every repo and every PR. Consequence: the dismissal is silently lost and the cancelled head re-fires a paid review after relaunch — the exact failure the dismissals store exists to prevent. This is also the one store module in the repo that reloads without an op-chain:pulls/local.ts:85-93,issues/local.ts:47-55,jira/store.ts:32-40,review-notes/store.ts:38-46,scripts/store.ts:26-35,ai/own-digest-store.ts:75-82,automations/store.ts:37-50andpulls/reviews-history.ts:65-73all pair the two, andautomations/store.ts:49states the invariant outright ("Call inside the serialized queue so it can't land between a set and its flush"). Fix: copy theopChain/serializehelper intodismissals.ts, wrap thefreshbranch ofgetDismissedHeadand the whole bodies ofsetDismissedHead/clearDismissedHeadin it, and start each write withawait reloadRaw()before thestore.getso the read-modify-write of the whole per-repo map is based on current disk state (mirroringmutateConfig,automations/store.ts:64-75) — that also stops a launch-time cache from clobbering another instance's cells under the same repo key. Keep thekeyFor/identityKeyForcall inside the queue, asrepo-identity.ts:69-70requires. Knock-on comment edits:reloadRaw's block comment (:34-37) should carry the "call inside the serialized queue" constraint likeautomations/store.ts:49, and the two flush comments (:97-99,:120-122) should say the flush pairs with the queued reload rather than with the gates' reload. Note on the reviewer note: "write paths remain un-serialized — pre-existing shape, out of scope" doesn't cover this — the reload that makes serialization necessary is introduced by this diff; dismissals never calledreload()before it.Readability / performance
src/lib/pulls/reviews-history.ts:163and:177: the exportedgetLatestReview/listReviewsgained theoptsparameter but their doc comments don't mentionfresh; the rationale lives only on the privateread(:146), whilegetDismissedHeaddocuments it on the public surface. Add one clause to each ("freshre-reads the store from disk first — seeread").src/lib/automations/sync.ts:189-204andsrc/lib/automations/runner.ts:278-331: both loops reload each store once per mode, so a two-mode event re-reads and re-parses the whole ofpr-reviews.json(which holds every review's full markdown, for every repo) twice, plusautomation-dismissals.jsontwice. Hoisting onelistReviews(..., { fresh: true })above the loop and derivingpriorper mode from it (it's already sorted newest-first, matchinggetLatestReview) halves it; a batchedgetDismissedHeads(repo, kind, ref, modes, opts)would do the same for dismissals.prior-context.tsas a UI caller, butrunner.ts:748callsresolvePriorContexton the automation path too (harmless here — the gate's reload has just refreshed the shared store instance); worth correcting so a later reader doesn't take "UI-only" at face value.Posted by GitDesktop — AI output, verify before acting on it.
Related
Tickets:
#2Originally posted by: theBGuy
Round-1 dispositions — the convergent should-fix and all three nits accepted; fixes land in the next push.
Dismissals serialization (should-fix, both reviewers independently) — fixed as prescribed, plus the reload-before-write upgrade. The correction to context item 6's "pre-existing shape" framing is accepted on the record: the un-serialized writes were pre-existing, but the reload actor is this diff's — dismissals never called
reload()before it. The module now carries the houseopChainqueue (hazard stated in its comment), the fresh read runs inside it, and both writers wrap their whole bodies in it starting with a reload-before-read-modify-write per themutateConfigprecedent — which also stops a launch-time cache from clobbering another instance's cells. Flush comments re-paired with the queued reload.Public-reader docs (nit) — fixed; both
getLatestReviewandlistReviewsnow carry thefreshclause.Per-mode double reload (nit) — fixed via per-event hoist. One fresh
listReviews+ one batchedgetDismissedHeadMap(new: whole-PR cells in a single queued read, store suffixes type-guarded) above the action loops in both the runner andprOpenEligible; gate predicates are character-identical, only the data source moved. The equivalence was walked explicitly for a two-mode event, including the tie-break case (stable sort ⇒ filter-then-index equals find-after-filter). One recorded consequence: stores are snapshotted once per event rather than read per action — within a run this can't change outcomes (modes never repeat in the action list), and cross-event overlap remains covered by the run claim.Consolidation while in there: the superseded single-cell
getDismissedHeadwas left with zero callers and has been removed.getLatestReview'sfreshoption stays despite currently having no fresh caller — deliberate API symmetry across the module's readers, recorded here so it isn't flagged as unexercised later.Context-item correction (nit, accepted): item 2 called
prior-context.tsa UI caller;resolvePriorContextalso runs on the automation path (harmless — the gate's queued reload has just refreshed the shared store instance). The item should read "non-gate callers" rather than "UI callers."Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedRound-2 changes fix the previous round's dismissals race exactly as prescribed and consolidate the gate reads into one snapshot per event. The serialization work is correct; the new per-event hoist introduces one narrow freshness gap in the multi-action case that I'd fix before merge but wouldn't block on.
Correctness
src/lib/automations/runner.ts:261-280(gateReviews/gateDismissedhoist), consumed at:300-310and:322-325: the twofreshsnapshots are now taken once, before the loop, and the loop body runs a full review between iterations. So the second action's gate decides on state that predates the first action's entire run (minutes to an hour — the timeout setting allows 60, per the comment at:116-117). Concrete case, needing only two windows on the same repo with both modes enabled: instance 1 fires an event at t=0 and snapshots; itsgeneralreview streams for N minutes; meanwhile in instance 2 the user cancels thesecurityautomation for the same head, which callsreleaseClaim()and thendismissOnCancel()(:467-468,:526-530) — the dismissal is now flushed to disk by this PR's own writer. At t=N instance 1 evaluatessecurityagainst the t=0gateDismissed, sees no dismissed head, wins the just-released claim, and posts a paid review the user explicitly cancelled elsewhere. Round 1's per-actionfreshread caught precisely this. The delivered-review variant is bounded by the claim's 30-minute stale window (:114-119, heartbeat stopped at settle,:582-586), so an action running >~35 minutes re-opens the duplicate-review path too. Your recorded note says this residual "remains covered by the run claim" — that holds for cross-event overlap inside 30 minutes, but not for the cancel case (the claim is released on cancel) nor past the stale window, so it's worth closing. Not a regression against master (which read the launch-time cache everywhere), which is why it isn't a blocker. Concrete fix that keeps the hoist's win on the common all-gates-skip poll path: make the two bindingslet, addlet snapshotStale = false;set totruein the loop'sfinally(:582-586, alongsidestopHeartbeat()), and at the top of both gate blocks doif (snapshotStale) { [gateReviews, gateDismissed] = await Promise.all([listReviews(...,{fresh:true}), getDismissedHeadMap(...,{fresh:true})]); snapshotStale = false; }— hoisting that refresh into a small local helper avoids duplicating the two calls. Reword the hoist comment at:261-265in the same edit ("…re-taken after any action that actually ran, so a later action still sees another instance's just-written record"), otherwise it becomes next round's stale-comment finding.Readability / performance
src/lib/automations/dismissals.ts:99-100:isReviewModeis a third hand-written copy of the mode literal pair (automations/types.ts:82ALL_ACTION_IDS,automations/store.ts:19ACTIONS,store.ts:96-98's private guard). If a third mode ever ships,getDismissedHeadMapsilently drops its cells and cancelled runs re-fire. Derive it instead:const isReviewMode = (v: string): v is ReviewMode => (ALL_ACTION_IDS as string[]).includes(v);withALL_ACTION_IDSimported from./types(no cycle —types.tsimports onlyai/typesandbranch-rules/match).src/lib/automations/dismissals.ts:115-119: the comment correctly calls the cell suffix untrusted but the value passes through unchecked, and it flows intosameSha(runner.ts:310,:325), whoselonger.startsWith(shorter)throws on a non-string from a hand-edited/corruptautomation-dismissals.json, silently killing the whole event viatriggerAutomations's catch. Extend the same guard line:if (isReviewMode(mode) && typeof headSha === "string") byMode[mode] = headSha;.src/lib/automations/runner.ts:261-280: the snapshots are now read above theonlyandbranchConditionsPassfilters, so an event whose every action is branch-excluded pays two full-store reloads (pr-reviews.jsoncarries every review's markdown) that it previously skipped entirely. Deferring them behind the same lazy helper the should-fix introduces (first gate use, memoized) removes that cost.Resolved since last review
opChain/serialize(dismissals.ts:34-45), fresh read queued (:88-97), both writers wrapped with reload-before-read-modify-write (:132-143,:159-170),reloadRaw's "call inside the serialized queue" constraint and both flush comments re-paired with the queued reload.keyForstays inside the queue, matchingrepo-identity.ts:62-70. No nestedserializein any queued op, so no self-deadlock.freshdocs — fixed (reviews-history.ts:163-164,:181-182).getDismissedHeadMap(runner.ts:261-280,sync.ts:190-196); I verified the gate equivalence holds (effectiveActionsemits at most one entry per action id, so no mode repeats in a pass, andlistReviews' newest-first sort makesfind(r => r.mode === …)identical togetLatestReview— both used only for truthiness).The unexercised
freshongetLatestReviewand the untouched README/site/help (fragment-only record) are recorded decisions; not re-flagging. The changelog fragment matcheschangelog.d/README.md's filename and bullet format.Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 dispositions — the should-fix and all three nits accepted and applied; fixes land in the next push (the closing batch).
Snapshot staleness across long-running actions (should-fix) — fixed, folding your third nit in. The correction to my round-1 note is accepted on the record: "cross-event overlap remains covered by the run claim" doesn't survive the cancel case, since cancel releases the claim. The eager hoist is now a lazy, memoized, run-invalidated accessor: the first gate use takes both fresh reads, later gate checks in the same pass share them, and the loop's
finally(which by structure only the ran path reaches) drops the snapshot so the next action re-reads — your two-window cancel scenario now lands: the dismissal flushed by the other window is visible to the later action's gate, which skips. All-filtered events touch nothing (zero reloads — nit 3), and commit events never reach the gates, so the commit special-casing is gone entirely. One shape deviation from the prescribed sketch, for the better: the accessor takes the already-narrowed PR-event type instead of closing over the un-narrowedevent, avoiding a dead commit branch. The hoist comment is rewritten to describe the new lifetime, cancel case included.Mode-guard duplication (nit) — fixed;
isReviewModenow derives fromALL_ACTION_IDS(confirmed no import cycle; tsc clean), so a future mode's cells surface instead of silently dropping.Untrusted value guard (nit) — fixed; the store value is
typeof-checked before it can reachsameSha's.startsWith. The guard is statically always-true against the declared map type — it exists for hand-edited/corrupt JSON, and the inline comment says so, recorded here so it isn't later flagged as dead code.One lifecycle note on the record: the claim-lost path deliberately does not invalidate the snapshot (nothing ran, and the claim winner hasn't written at that instant) — the sharing there is the retained perf win.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedRound-3 closes the previous round's freshness gap correctly: the eager hoist is now a lazy, memoized snapshot invalidated in the loop's
finally, and I verified the invalidation lands on every path that entered thetry(the claim-lostcontinueatrunner.ts:368sits before thetry, matching the recorded lifecycle note). Nothing blocking; only two small polish items remain.Readability / performance
src/lib/automations/runner.ts:276-289(gateState) andsrc/lib/automations/sync.ts:193-196(prOpenEligible): the two fresh reads are awaited sequentially, so each gate pays two disk reloads back-to-back (prOpenEligibleis on the Mark-ready click path). They hit different stores with independentserializequeues, so they can run concurrently:const [reviews, dismissed] = await Promise.all([listReviews(…, { fresh: true }), getDismissedHeadMap(…, { fresh: true })]);— ingateStateassign that togateSnapshotas{ reviews, dismissed }(the??=guard still reads before the awaits, which is safe for the same sequential-loop reason the existing comment states).src/lib/automations/runner.ts:261-268vs:599-602: the snapshot's lifetime is now documented twice (the 8-line hoist comment plus the 3-linefinallycomment), and the hoist block runs past the repo's ~6-line ceiling for multi-constraint comments. Drop the "then dropped after any action that actually RAN (see the loop'sfinally)" clause from the hoist comment and let thefinally's own comment own the invalidation rule.Resolved since last review
gateState(runner.ts:275-291) takes both fresh reads on first gate use, later gates in the same pass share them, andgateSnapshot = nullin thefinally(:602) forces a re-read for the next action. Your two-window cancel case now lands:generalstreams, instance 2's cancel flushes asecuritydismissal viasetDismissedHead, andsecurity's gate re-reads and skips atsameSha(dismissedHead ?? "", headSha). The comment's "entered only past every gate" holds — the last gate (if (!won) continue) is at:368, above thetry.isReviewModederives fromALL_ACTION_IDS(dismissals.ts:100-103);types.ts:8confirmsActionId = ReviewModeso the type predicate is sound, andtypes.tsimports onlyai/types+branch-rules/match, so no cycle.sameSha(nit) — fixed:typeof headSha === "string"guards the assignment (dismissals.ts:123-124) before the value can reachlonger.startsWith(shorter)(sync.ts:27).gateStateis only called from inside thepr-sync/pr-opengate blocks, below theonlyandbranchConditionsPassfilters, and commit events never reach them.serialize/opChain(dismissals.ts:40-46), fresh read queued (:94-97), both writers reload-then-read-modify-write-then-save()inside the queue (:137-148,:164-175), no nestedserialize.Leftover polish (non-blocking)
src/lib/automations/sync.ts:189—const modes = ["general", "security"] as const;is the remaining hand-written copy of the mode pair, the same class as theisReviewModenit just fixed;for (const mode of ALL_ACTION_IDS)(imported from./types) keepsdismissedByMode[mode]type-correct and surfaces a future third mode.Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 dispositions — all three nits accepted and applied as the closing batch.
Concurrent fresh reads — fixed, with one correction to the prescribed shape. The sketch put
Promise.allin the??=expression, which would fire both reads even when the snapshot is already memoized; the applied form keeps the memo check first (if (!gateSnapshot) { const [reviews, dismissed] = await Promise.all(...); ... }), so a second gate check in the same pass still reads nothing. Verified in the final code. SamePromise.allinprOpenEligible(the Mark-ready path). Error-path note for the record: an unobserved second rejection underPromise.allchanges nothing at either call site —prOpenEligibleis fail-closed by its own catch, the runner's gates propagate intotriggerAutomations' existing catch, andreloadRawswallows missing-file reloads anyway.Comment dedup — fixed; the hoist block is down to 5 lines and the invalidation rule lives only at the
finally. One sentence more than prescribed was removed: the cancel-window rationale explained a rule no longer stated there, and it survives in full at thefinally.Last mode-pair literal — fixed;
prOpenEligibleiteratesALL_ACTION_IDS(same pair, same order, types clean with no cast), closing the hand-copy class completely across this PR's surface.This is the closing batch; with two consecutive merge-when-ready verdicts, the push and merge are the author's call.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedRound-4 applies the three round-3 nits cleanly; I re-verified the new hunks (
runner.ts:272-289,sync.ts:190-202) as first-class code and found no collateral. Nothing blocking, nothing further to raise.Resolved since last review
gateState(runner.ts:273-287) keeps the memo check before the awaits, so a second gate check in the same pass still reads nothing, and only the first pays;prOpenEligible(sync.ts:194-197) does the same on the Mark-ready path. The two reads hit different stores with independentserializequeues (reviews-history.ts:65-71,dismissals.ts:40-46), so concurrency introduces no ordering hazard, andPromise.allsubscribes to both promises, so a second rejection can't surface as an unhandled rejection —prOpenEligiblestill fails closed via its owncatch, and the runner's gate rejection still propagates intotriggerAutomations' catch.runner.ts:261-265) and states only the sharing/laziness rule; the invalidation rule lives once, at thefinally(:597-600), which still names both the delivery and the other-window cancel case. No detached or now-inaccurate doc text left behind.prOpenEligible(previous round's### Leftover polish) — fixed.sync.ts:198iteratesALL_ACTION_IDS(imported at:4);types.ts:8(ActionId = ReviewMode) and:82confirm same set and same order, sodismissedByMode[mode]andr.mode === modestay type-correct with no cast, andtypes.tsimports onlyai/types+branch-rules/match, so the newsync.ts → types.tsedge adds no cycle. Thefind-on-newest-first substitution forgetLatestReviewremains an existence check, so it's equivalent.Nothing further: the gate equivalences, the
finallyinvalidation reaching every path that enters thetry(the claim-lostcontinueat:366still sits above it, per the recorded lifecycle decision), the queued reload ordering the fire-and-forgetdismissOnCancelwrite ahead of the next gate's fresh read, and the changelog fragment's name/body format all check out.Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy