fix: handle worktree lifecycle on mid-session milestone transitions (#616) (#618)

This commit is contained in:
Flux Labs 2026-03-16 08:38:29 -05:00 committed by GitHub
parent 9ffb927856
commit c8f8795e73
2 changed files with 214 additions and 2 deletions

View file

@ -1310,8 +1310,76 @@ async function dispatchNextUnit(
unitDispatchCount.clear();
unitRecoveryCount.clear();
unitLifetimeDispatches.clear();
// Capture integration branch for the new milestone and update git service
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
// Clear completed-units.json for the finished milestone
try {
const file = completedKeysPath(basePath);
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
completedKeySet.clear();
} catch { /* non-fatal */ }
// ── Worktree lifecycle on milestone transition (#616) ──────────────
// When transitioning from M_old to M_new inside a worktree, we must:
// 1. Merge the completed milestone's worktree back to main
// 2. Re-derive state from the project root
// 3. Create a new worktree for the incoming milestone
// Without this, M_new runs inside M_old's worktree on the wrong branch,
// and artifact paths resolve against the wrong .gsd/ directory.
if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) {
try {
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
if (roadmapPath) {
const roadmapContent = readFileSync(roadmapPath, "utf-8");
const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
ctx.ui.notify(
`Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
"info",
);
} else {
// No roadmap found — teardown worktree without merge
teardownAutoWorktree(originalBasePath, currentMilestoneId);
ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info");
}
} catch (err) {
ctx.ui.notify(
`Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`,
"warning",
);
// Force cwd back to project root even if merge failed
if (originalBasePath) {
try { process.chdir(originalBasePath); } catch { /* best-effort */ }
}
}
// Update basePath to project root (mergeMilestoneToMain already chdir'd)
basePath = originalBasePath;
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
invalidateAllCaches();
// Re-derive state from project root before creating new worktree
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
// Create new worktree for the incoming milestone
if (mid) {
captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
try {
const wtPath = createAutoWorktree(basePath, mid);
basePath = wtPath;
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
} catch (err) {
ctx.ui.notify(
`Auto-worktree creation for ${mid} failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
"warning",
);
}
}
} else {
// Not in worktree — just capture integration branch for the new milestone
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
}
// Prune completed milestone from queue order file
const pendingIds = state.registry
.filter(m => m.status !== "complete")

View file

@ -0,0 +1,144 @@
/**
* milestone-transition-worktree.test.ts Tests for #616 fix.
*
* Verifies that when auto-mode transitions between milestones, the
* worktree lifecycle is handled: old worktree merged, new worktree created.
*
* Uses source-level checks since the full auto-mode dispatch loop
* requires the @gsd/pi-coding-agent runtime.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import {
createAutoWorktree,
teardownAutoWorktree,
isInAutoWorktree,
getAutoWorktreeOriginalBase,
mergeMilestoneToMain,
} from "../auto-worktree.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-mt-wt-test-")));
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
return dir;
}
function createMilestoneArtifacts(dir: string, mid: string): void {
const msDir = join(dir, ".gsd", "milestones", mid);
mkdirSync(msDir, { recursive: true });
writeFileSync(join(msDir, "CONTEXT.md"), `# ${mid} Context\n`);
const roadmap = [
`# ${mid}: Test Milestone`,
"**Vision**: testing",
"## Success Criteria",
"- It works",
"## Slices",
"- [x] S01 — First slice",
].join("\n");
writeFileSync(join(msDir, `${mid}-ROADMAP.md`), roadmap);
}
// ─── Milestone transition: worktree swap ─────────────────────────────────────
test("worktree swap on milestone transition: merge old, create new", () => {
const savedCwd = process.cwd();
let tempDir = "";
try {
tempDir = createTempRepo();
// Set up M001 and M002 milestone artifacts
createMilestoneArtifacts(tempDir, "M001");
createMilestoneArtifacts(tempDir, "M002");
run("git add .", tempDir);
run("git commit -m \"add milestones\"", tempDir);
// Phase 1: Create worktree for M001 (simulates auto-mode start)
const wt1 = createAutoWorktree(tempDir, "M001");
assert.equal(process.cwd(), wt1, "cwd should be in M001 worktree");
assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree");
assert.equal(getAutoWorktreeOriginalBase(), tempDir, "original base preserved");
// Add a commit in M001 worktree to simulate work
writeFileSync(join(wt1, "feature-m001.txt"), "M001 work\n");
run("git add .", wt1);
run("git commit -m \"feat(M001): add feature\"", wt1);
// Phase 2: Simulate milestone transition — merge M001, exit worktree
const roadmapPath = join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
const roadmapContent = readFileSync(roadmapPath, "utf-8");
mergeMilestoneToMain(tempDir, "M001", roadmapContent);
// After merge: cwd should be back at project root
assert.equal(process.cwd(), tempDir, "cwd restored to project root after merge");
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree after merge");
// Verify M001 work was merged to main
const mainLog = run("git log --oneline -3", tempDir);
assert.ok(mainLog.includes("M001"), "M001 squash commit should be on main");
// Phase 3: Create new worktree for M002 (simulates new milestone)
const wt2 = createAutoWorktree(tempDir, "M002");
assert.equal(process.cwd(), wt2, "cwd should be in M002 worktree");
assert.ok(isInAutoWorktree(tempDir), "should be in M002 auto-worktree");
// The new worktree should have the M001 feature file (merged to main)
assert.ok(existsSync(join(wt2, "feature-m001.txt")), "M002 worktree inherits M001 merged work");
// Verify branch is correct
const branch = run("git branch --show-current", wt2);
assert.equal(branch, "milestone/M002", "M002 worktree on correct branch");
// Cleanup
teardownAutoWorktree(tempDir, "M002");
} finally {
process.chdir(savedCwd);
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
}
});
// ─── Verify the transition code path exists in auto.ts ──────────────────────
test("auto.ts milestone transition block contains worktree lifecycle", () => {
const autoSrc = readFileSync(
join(__dirname, "..", "auto.ts"),
"utf-8",
);
// The fix adds worktree merge + create inside the milestone transition block
assert.ok(
autoSrc.includes("Worktree lifecycle on milestone transition"),
"auto.ts should contain the worktree lifecycle comment marker",
);
assert.ok(
autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== currentMilestoneId"),
"auto.ts should call mergeMilestoneToMain during milestone transition",
);
assert.ok(
autoSrc.includes("createAutoWorktree") && autoSrc.includes("Created auto-worktree for"),
"auto.ts should create new worktree for incoming milestone",
);
});