From 675d6b23169c85c2633d96d3f11b64245d261ca1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 13:56:50 -0400 Subject: [PATCH] fix(sync): use stable dedup key and skip threads when head unresolved --- packages/backend/convex/githubSync.ts | 145 ++++++++++++++++++-------- 1 file changed, 100 insertions(+), 45 deletions(-) diff --git a/packages/backend/convex/githubSync.ts b/packages/backend/convex/githubSync.ts index ac79d7c..9af2d71 100644 --- a/packages/backend/convex/githubSync.ts +++ b/packages/backend/convex/githubSync.ts @@ -16,6 +16,7 @@ import { listPullRequests, syncForkBranch, } from './githubClient'; +import { maintenanceDedupKey } from './maintenanceDedup'; import { shouldAutoSync, shouldCreateReviewThread } from './syncGating'; const getRequiredUserId = async (ctx: ActionCtx) => { @@ -244,6 +245,32 @@ const refreshOwnedSpoon = async ( } catch (syncError) { const message = syncError instanceof Error ? syncError.message : String(syncError); + const dedupKey = maintenanceDedupKey({ + mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, + headSha: upstreamCompare.headSha, + }); + if (!dedupKey) { + console.warn( + 'Skipping maintenance thread: upstream head SHA unresolvable for spoon', + spoonId, + ); + await ctx.runMutation(internal.syncRuns.patchInternal, { + syncRunId, + status: 'failed', + error: `${message} (skipped maintenance thread: could not resolve upstream head SHA)`, + }); + await ctx.runMutation(internal.spoons.patchSyncFields, { + spoonId, + syncStatus: 'conflict', + lastError: message, + }); + return { + success: true, + status: 'unknown' as const, + upstreamAheadBy: upstreamCompare.aheadBy, + forkAheadBy: forkCompare.aheadBy, + }; + } const threadId = await ctx.runMutation( internal.threads.createMaintenanceThread, { @@ -253,7 +280,8 @@ const refreshOwnedSpoon = async ( title: `Resolve upstream sync conflict for ${spoon.name}`, summary: `GitHub refused the automatic upstream sync: ${message}`, upstreamFrom: upstreamCompare.mergeBaseSha, - upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`, + upstreamTo: upstreamCompare.headSha, + dedupKey, forkHeadAtCreation: forkCompare.headSha, mergeBaseAtCreation: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, @@ -283,28 +311,46 @@ const refreshOwnedSpoon = async ( } if (shouldCreateReviewThread({ autoReviewEnabled }, { status })) { - const threadId = await ctx.runMutation( - internal.threads.createMaintenanceThread, - { - spoonId, - ownerId, - source: 'upstream_update', - title: `Review upstream changes for ${spoon.name}`, - summary: `Upstream has ${upstreamCompare.aheadBy} commit(s) and the fork has ${forkCompare.aheadBy} custom commit(s). Review whether upstream should be merged, ignored, or resolved in a draft PR.`, - upstreamFrom: upstreamCompare.mergeBaseSha, - upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`, - forkHeadAtCreation: forkCompare.headSha, - mergeBaseAtCreation: - upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, - relatedSyncRunId: syncRunId, - jobType: 'maintenance_review', - }, - ); - await ctx.runMutation(internal.syncRuns.patchInternal, { - syncRunId, - threadId, - decision: 'thread_created', + const dedupKey = maintenanceDedupKey({ + mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, + headSha: upstreamCompare.headSha, }); + if (!dedupKey) { + console.warn( + 'Skipping maintenance thread: upstream head SHA unresolvable for spoon', + spoonId, + ); + await ctx.runMutation(internal.syncRuns.patchInternal, { + syncRunId, + status: 'failed', + error: + 'Could not resolve upstream head SHA; skipping maintenance thread.', + }); + } else { + const threadId = await ctx.runMutation( + internal.threads.createMaintenanceThread, + { + spoonId, + ownerId, + source: 'upstream_update', + title: `Review upstream changes for ${spoon.name}`, + summary: `Upstream has ${upstreamCompare.aheadBy} commit(s) and the fork has ${forkCompare.aheadBy} custom commit(s). Review whether upstream should be merged, ignored, or resolved in a draft PR.`, + upstreamFrom: upstreamCompare.mergeBaseSha, + upstreamTo: upstreamCompare.headSha, + dedupKey, + forkHeadAtCreation: forkCompare.headSha, + mergeBaseAtCreation: + upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, + relatedSyncRunId: syncRunId, + jobType: 'maintenance_review', + }, + ); + await ctx.runMutation(internal.syncRuns.patchInternal, { + syncRunId, + threadId, + decision: 'thread_created', + }); + } } return { success: true, @@ -408,30 +454,39 @@ export const syncForkWithUpstream = action({ const message = error instanceof Error ? error.message : String(error); const conflict = message.toLowerCase().includes('conflict'); if (conflict) { - const threadId = await ctx.runMutation( - internal.threads.createMaintenanceThread, - { - spoonId, - ownerId, - source: 'merge_conflict', - title: `Resolve upstream sync conflict for ${spoon.name}`, - summary: `GitHub reported a conflict while syncing upstream into this fork: ${message}`, - upstreamTo: - state.upstreamHeadSha ?? - spoon.lastUpstreamCommit ?? - `${Date.now()}`, - forkHeadAtCreation: state.forkHeadSha ?? spoon.lastForkCommit, - mergeBaseAtCreation: - state.mergeBaseSha ?? spoon.lastMergeBaseCommit, - relatedSyncRunId: syncRunId, - jobType: 'conflict_resolution', - }, - ); - await ctx.runMutation(internal.syncRuns.patchInternal, { - syncRunId, - threadId, - decision: 'thread_created', + const dedupKey = maintenanceDedupKey({ + mergeBaseSha: state.mergeBaseSha ?? spoon.lastMergeBaseCommit, + headSha: state.upstreamHeadSha ?? spoon.lastUpstreamCommit, }); + if (!dedupKey) { + console.warn( + 'Skipping maintenance thread: upstream head SHA unresolvable for spoon', + spoonId, + ); + } else { + const threadId = await ctx.runMutation( + internal.threads.createMaintenanceThread, + { + spoonId, + ownerId, + source: 'merge_conflict', + title: `Resolve upstream sync conflict for ${spoon.name}`, + summary: `GitHub reported a conflict while syncing upstream into this fork: ${message}`, + upstreamTo: state.upstreamHeadSha ?? spoon.lastUpstreamCommit, + dedupKey, + forkHeadAtCreation: state.forkHeadSha ?? spoon.lastForkCommit, + mergeBaseAtCreation: + state.mergeBaseSha ?? spoon.lastMergeBaseCommit, + relatedSyncRunId: syncRunId, + jobType: 'conflict_resolution', + }, + ); + await ctx.runMutation(internal.syncRuns.patchInternal, { + syncRunId, + threadId, + decision: 'thread_created', + }); + } } await ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId,