* 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:
parent
5fec6ea81e
commit
db9f006f19
3 changed files with 124 additions and 62 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue