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
This commit is contained in:
Tom Boucher 2026-03-16 11:11:26 -04:00 committed by GitHub
parent 5fec6ea81e
commit db9f006f19
3 changed files with 124 additions and 62 deletions

View file

@ -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 });
}
/**

View file

@ -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;

View file

@ -94,7 +94,7 @@ export function worktreeBranchName(name: string): string {
*
* @param opts.branch override the default `worktree/<name>` 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);
}