From 2b9451dfd49f2c613137a9170041477e2c2c14c6 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:44:55 -0500 Subject: [PATCH] fix: general merge guard prevents infinite loop when complete-slice is bypassed (#71) Replace the narrow 'if currentUnit === complete-slice' merge check with a general merge guard that detects any completed slice branch and merges it to main before dispatching the next unit. The old check only triggered merges after the complete-slice unit type. When the LLM or the doctor post-hook completed slice bookkeeping during task execution, complete-slice was skipped entirely, leaving the slice branch unmerged. On milestone transition, the next slice branch (forked from main) couldn't see the prior milestone's summary, causing deriveState to oscillate between milestones in an infinite loop. The new guard checks: are we on a gsd/MID/SID branch where the roadmap entry is [x]? If so, merge to main and re-derive state before dispatching. --- src/resources/extensions/gsd/auto.ts | 74 +++++++++++++++++----------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index b628d16ec..16fdd3431 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -60,6 +60,8 @@ import { execSync } from "node:child_process"; import { autoCommitCurrentBranch, ensureSliceBranch, + getCurrentBranch, + getSliceBranchName, switchToMain, mergeSliceToMain, } from "./worktree.ts"; @@ -800,39 +802,53 @@ async function dispatchNextUnit( return; } - // ── Post-completion merge: merge the slice branch after complete-slice finishes ── - // The complete-slice unit writes the summary, UAT, marks roadmap [x], and commits. - // Now we switch to main and squash-merge the slice branch. - if (currentUnit?.type === "complete-slice") { - try { - const [completedMid, completedSid] = currentUnit.id.split("/"); - // Look up actual slice title from roadmap (on current branch, before switching) - const roadmapFile = resolveMilestoneFile(basePath, completedMid!, "ROADMAP"); + // ── General merge guard: merge completed slice branches before advancing ── + // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]), + // merge to main before dispatching the next unit. This handles: + // - Normal complete-slice → merge → reassess flow + // - LLM writes summary during task execution, skipping complete-slice + // - Doctor post-hook marks everything done, skipping complete-slice + // - complete-milestone runs on a slice branch (last slice bypass) + { + const currentBranch = getCurrentBranch(basePath); + const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/); + if (branchMatch) { + const branchMid = branchMatch[1]!; + const branchSid = branchMatch[2]!; + // Check if this slice is marked done in the roadmap + const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP"); const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - let sliceTitleForMerge = completedSid!; if (roadmapContent) { const roadmap = parseRoadmap(roadmapContent); - const sliceEntry = roadmap.slices.find(s => s.id === completedSid); - if (sliceEntry) sliceTitleForMerge = sliceEntry.title; + const sliceEntry = roadmap.slices.find(s => s.id === branchSid); + if (sliceEntry?.done) { + try { + const sliceTitleForMerge = sliceEntry.title || branchSid; + switchToMain(basePath); + const mergeResult = mergeSliceToMain( + basePath, branchMid, branchSid, sliceTitleForMerge, + ); + ctx.ui.notify( + `Merged ${mergeResult.branch} → main.`, + "info", + ); + // Re-derive state from main so downstream logic sees merged state + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify( + `Slice merge failed: ${message}`, + "error", + ); + // Re-derive state so dispatch can figure out what to do + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } + } } - switchToMain(basePath); - const mergeResult = mergeSliceToMain( - basePath, completedMid!, completedSid!, sliceTitleForMerge, - ); - ctx.ui.notify( - `Merged ${mergeResult.branch} → main.`, - "info", - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify( - `Slice merge failed: ${message}`, - "error", - ); - // Re-derive state so dispatch can figure out what to do - state = await deriveState(basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; } }