From db9f006f1916cf1423cd3c8117a5170811f45dc6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 11:11:26 -0400 Subject: [PATCH] fix(auto): preserve milestone branch on stop to prevent work loss (#601) (#632) * fix(auto): preserve milestone branch on stop to prevent work loss (#601) When auto-mode stops mid-milestone, the worktree teardown was force-deleting the milestone branch (git branch -D). On the next /gsd auto, a fresh branch was created from the integration branch, losing all committed work from the prior session. This caused auto-mode to re-trigger milestone planning instead of resuming execution. Three changes: 1. stopAuto: pass preserveBranch: true to teardownAutoWorktree so the milestone branch survives. Also auto-commit dirty state before leaving the worktree. 2. createAutoWorktree: when the milestone branch already exists, re-attach the worktree to it as-is instead of force-resetting it to the integration branch (which would also destroy prior work). 3. startAuto: detect surviving milestone branches when state appears to be pre-planning. Skip the early-return to discuss/plan flow and let the worktree setup + dispatch handle it from the branch's actual state. The branch is still deleted during mergeMilestoneToMain (milestone completion) after the work has been squash-merged, so no cleanup change is needed there. * fix: add null guard for state.activeMilestone to satisfy TypeScript --- src/resources/extensions/gsd/auto-worktree.ts | 30 +++- src/resources/extensions/gsd/auto.ts | 141 +++++++++++------- .../extensions/gsd/worktree-manager.ts | 15 +- 3 files changed, 124 insertions(+), 62 deletions(-) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 0bb65ae67..10c95479e 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -33,6 +33,7 @@ import { nativeAddPaths, nativeRmForce, nativeBranchDelete, + nativeBranchExists, } from "./native-git-bridge.js"; // ─── Module State ────────────────────────────────────────────────────────── @@ -93,11 +94,21 @@ export function autoWorktreeBranch(milestoneId: string): string { export function createAutoWorktree(basePath: string, milestoneId: string): string { const branch = autoWorktreeBranch(milestoneId); - // Use the integration branch recorded in META.json as the start point. - // This ensures the worktree branch is created from the branch the user - // was on when they started the milestone (e.g. f-setup-gsd-2), not main. - const integrationBranch = readIntegrationBranch(basePath, milestoneId) ?? undefined; - const info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch }); + // Check if the milestone branch already exists — it survives auto-mode + // stop/pause and contains committed work from prior sessions. If it exists, + // re-attach the worktree to it WITHOUT resetting. Only create a fresh branch + // from the integration branch when no prior work exists. + const branchExists = nativeBranchExists(basePath, branch); + + let info: { name: string; path: string; branch: string; exists: boolean }; + if (branchExists) { + // Re-attach worktree to the existing milestone branch (preserving commits) + info = createWorktree(basePath, milestoneId, { branch, reuseExistingBranch: true }); + } else { + // Fresh start — create branch from integration branch + const integrationBranch = readIntegrationBranch(basePath, milestoneId) ?? undefined; + info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch }); + } // Copy .gsd/ planning artifacts from the source repo into the new worktree. // Worktrees are fresh git checkouts — untracked files don't carry over. @@ -157,8 +168,13 @@ function copyPlanningArtifacts(srcBase: string, wtPath: string): void { * Teardown an auto-worktree: chdir back to original base, then remove * the worktree and its branch. */ -export function teardownAutoWorktree(originalBasePath: string, milestoneId: string): void { +export function teardownAutoWorktree( + originalBasePath: string, + milestoneId: string, + opts: { preserveBranch?: boolean } = {}, +): void { const branch = autoWorktreeBranch(milestoneId); + const { preserveBranch = false } = opts; const previousCwd = process.cwd(); try { @@ -171,7 +187,7 @@ export function teardownAutoWorktree(originalBasePath: string, milestoneId: stri } nudgeGitBranchCache(previousCwd); - removeWorktree(originalBasePath, milestoneId, { branch }); + removeWorktree(originalBasePath, milestoneId, { branch, deleteBranch: !preserveBranch }); } /** diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 8872863da..873742f1d 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -482,12 +482,17 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi deregisterSigtermHandler(); // ── Auto-worktree: exit worktree and reset basePath on stop ── + // Preserve the milestone branch so the next /gsd auto can re-enter + // where it left off. The branch is only deleted during milestone + // completion (mergeMilestoneToMain) after the work has been squash-merged. if (currentMilestoneId && isInAutoWorktree(basePath)) { try { - teardownAutoWorktree(originalBasePath, currentMilestoneId); + // Auto-commit any dirty state before leaving so work isn't lost + try { autoCommitCurrentBranch(basePath, "stop", currentMilestoneId); } catch { /* non-fatal */ } + teardownAutoWorktree(originalBasePath, currentMilestoneId, { preserveBranch: true }); basePath = originalBasePath; gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); - ctx?.ui.notify("Exited auto-worktree.", "info"); + ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info"); } catch (err) { ctx?.ui.notify( `Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`, @@ -727,68 +732,102 @@ export async function startAuto( let state = await deriveState(base); - // No active work at all — start a new milestone via the discuss flow. - // After discussion completes, checkAutoStartAfterDiscuss() (fired from - // agent_end) will detect the new CONTEXT.md and restart auto mode. - // If the LLM didn't follow the discussion protocol (e.g. started editing - // files directly for a simple task), we re-derive state and either proceed - // with what was created or notify the user clearly (#609). - if (!state.activeMilestone || state.phase === "complete") { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - - // Re-derive state after discussion — the LLM may have created artifacts - // even if it didn't follow the full protocol. - invalidateAllCaches(); - const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") { - // Discussion produced enough artifacts to proceed — fall through - // to auto mode activation below instead of returning. - state = postState; - } else if (postState.activeMilestone && postState.phase === "pre-planning") { - // Milestone directory exists but no context — check if context was written - const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (hasContext) { - state = postState; - // Fall through — auto mode will research + plan it - } else { - ctx.ui.notify( - "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", - "warning", - ); - return; - } - } else { - return; + // ── Milestone branch recovery (#601) ───────────────────────────────────── + // When auto-mode was previously stopped, the milestone branch is preserved + // but the worktree is removed. The project root (integration branch) may + // not have the roadmap/artifacts — they live on the milestone branch. + // If state looks like pre-planning but a milestone branch exists with prior + // work, skip the early-return checks and let worktree setup + dispatch + // handle it correctly from the branch's state. + let hasSurvivorBranch = false; + if ( + state.activeMilestone && + (state.phase === "pre-planning" || state.phase === "needs-discussion") && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) && + !base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`) + ) { + const milestoneBranch = `milestone/${state.activeMilestone.id}`; + const { nativeBranchExists } = await import("./native-git-bridge.js"); + hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); + if (hasSurvivorBranch) { + ctx.ui.notify( + `Found prior session branch ${milestoneBranch}. Resuming.`, + "info", + ); } } - // Active milestone exists but has no roadmap — check if context exists. - // If context was pre-written (multi-milestone planning), auto-mode can - // research and plan it. If no context either, need user discussion. - if (state.phase === "pre-planning") { - const mid = state.activeMilestone!.id; - const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (!hasContext) { + if (!hasSurvivorBranch) { + // No active work at all — start a new milestone via the discuss flow. + // After discussion completes, checkAutoStartAfterDiscuss() (fired from + // agent_end) will detect the new CONTEXT.md and restart auto mode. + // If the LLM didn't follow the discussion protocol (e.g. started editing + // files directly for a simple task), we re-derive state and either proceed + // with what was created or notify the user clearly (#609). + if (!state.activeMilestone || state.phase === "complete") { const { showSmartEntry } = await import("./guided-flow.js"); await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - // Same re-derive pattern as above + // Re-derive state after discussion — the LLM may have created artifacts + // even if it didn't follow the full protocol. invalidateAllCaches(); const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "pre-planning") { + if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") { state = postState; + } else if (postState.activeMilestone && postState.phase === "pre-planning") { + const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (hasContext) { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", + "warning", + ); + return; + } } else { - ctx.ui.notify( - "Discussion completed but milestone context is still missing. Run /gsd to try again.", - "warning", - ); return; } } - // Has context, no roadmap — auto-mode will research + plan it + + // Active milestone exists but has no roadmap — check if context exists. + // If context was pre-written (multi-milestone planning), auto-mode can + // research and plan it. If no context either, need user discussion. + if (state.phase === "pre-planning") { + const mid = state.activeMilestone!.id; + const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (!hasContext) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + + // Same re-derive pattern as above + invalidateAllCaches(); + const postState = await deriveState(base); + if (postState.activeMilestone && postState.phase !== "pre-planning") { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but milestone context is still missing. Run /gsd to try again.", + "warning", + ); + return; + } + } + // Has context, no roadmap — auto-mode will research + plan it + } + } + + // At this point activeMilestone is guaranteed non-null: either + // hasSurvivorBranch is true (which requires activeMilestone) or + // the !activeMilestone early-return above would have fired. + if (!state.activeMilestone) { + // Unreachable — satisfies TypeScript's null check + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + return; } active = true; diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 99fbf003e..0a7a36746 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -94,7 +94,7 @@ export function worktreeBranchName(name: string): string { * * @param opts.branch — override the default `worktree/` branch name */ -export function createWorktree(basePath: string, name: string, opts: { branch?: string; startPoint?: string } = {}): WorktreeInfo { +export function createWorktree(basePath: string, name: string, opts: { branch?: string; startPoint?: string; reuseExistingBranch?: boolean } = {}): WorktreeInfo { // Validate name: alphanumeric, hyphens, underscores only if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`); @@ -133,9 +133,16 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: ); } - // Reset the stale branch to the start point, then attach worktree to it - nativeBranchForceReset(basePath, branch, startPoint); - nativeWorktreeAdd(basePath, wtPath, branch); + if (opts.reuseExistingBranch) { + // Attach worktree to the existing branch as-is (preserving commits). + // Used when resuming auto-mode: the milestone branch has valid work + // from prior sessions that must not be reset. + nativeWorktreeAdd(basePath, wtPath, branch); + } else { + // Reset the stale branch to the start point, then attach worktree to it + nativeBranchForceReset(basePath, branch, startPoint); + nativeWorktreeAdd(basePath, wtPath, branch); + } } else { nativeWorktreeAdd(basePath, wtPath, branch, true, startPoint); }