fix: reconcile plan checkboxes on worktree re-attach after crash (#778) (#794)

Root cause:
  When auto-mode stops via crash (not graceful stop), the milestone branch
  HEAD lags behind the filesystem state at the project root. This is because
  syncStateToProjectRoot() runs after every task completion and writes [x]
  checkboxes to the project root, but the auto-commit that would record
  those changes to the milestone branch may not have fired before the crash.

  On restart, createAutoWorktree() re-attaches the worktree to the milestone
  branch HEAD (which still has [ ] for the crashed task). When dispatchNextUnit()
  then runs verifyExpectedArtifact(), it reads the plan from the worktree and
  finds [ ], treats the completion record as stale, evicts the key, and
  re-dispatches — entering an infinite skip/dispatch loop.

Fix:
  Add reconcilePlanCheckboxes(projectRoot, wtPath, milestoneId) which is called
  when re-attaching to an existing branch. It walks every markdown file in the
  milestone directory at the project root and forward-applies any [x] checkbox
  states that are ahead of the worktree version (never downgrades [x] → [ ]).
  It also forward-merges completed-units.json.

  The project root is the correct source of truth here because
  syncStateToProjectRoot() is called after every task completion and keeps
  it up-to-date. If the branch HEAD is behind due to a crash, the root
  filesystem copy is the last known good state.

  This is safe: we only ever advance checkbox state ([ ] → [x]), never
  regress it. The milestone branch content is preserved for all other files.

Test:
  Added test case to auto-worktree.test.ts covering the exact scenario:
  - T01 [x] committed to milestone branch, T02 [x] written to project root
    (by syncStateToProjectRoot) but commit never fired
  - Worktree re-created from milestone branch HEAD (T02 still [ ])
  - After reconciliation, worktree plan has T02 [x], T03 stays [ ]
This commit is contained in:
Jeremy McSpadden 2026-03-16 23:14:34 -05:00 committed by GitHub
parent 2b3846fab9
commit 3ea832c166
2 changed files with 177 additions and 1 deletions

View file

@ -6,7 +6,7 @@
* manages create, enter, detect, and teardown for auto-mode worktrees.
*/
import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { existsSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync, utimesSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js";
import { execSync, execFileSync } from "node:child_process";
@ -134,6 +134,112 @@ export function autoWorktreeBranch(milestoneId: string): string {
* Atomic: chdir + originalBase update happen in the same try block
* to prevent split-brain.
*/
/**
* Forward-merge plan checkbox state from the project root into a freshly
* re-attached worktree (#778).
*
* When auto-mode stops via crash (not graceful stop), the milestone branch
* HEAD may be behind the filesystem state at the project root because
* syncStateToProjectRoot() runs after every task completion but the final
* git commit may not have happened before the crash. On restart the worktree
* is re-attached to the branch HEAD, which has [ ] for the crashed task,
* causing verifyExpectedArtifact() to fail and triggering an infinite
* dispatch/skip loop.
*
* Fix: after re-attaching, read every *.md plan file in the milestone
* directory at the project root and apply any [x] checkbox states that are
* ahead of the worktree version (forward-only: never downgrade [x] [ ]).
*
* This is safe because syncStateToProjectRoot() is the authoritative source
* of post-task state at the project root it writes the same [x] the LLM
* produced, then the auto-commit follows. If the commit never happened, the
* filesystem copy is still valid and correct.
*/
function reconcilePlanCheckboxes(projectRoot: string, wtPath: string, milestoneId: string): void {
const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId);
const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId);
if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return;
// Walk all markdown files in the milestone directory (plans, summaries, etc.)
function walkMd(dir: string): string[] {
const results: string[] = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkMd(full));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(full);
}
}
} catch { /* non-fatal */ }
return results;
}
for (const srcFile of walkMd(srcMilestone)) {
const rel = srcFile.slice(srcMilestone.length);
const dstFile = dstMilestone + rel;
if (!existsSync(dstFile)) continue; // only reconcile existing files
let srcContent: string;
let dstContent: string;
try {
srcContent = readFileSync(srcFile, "utf-8");
dstContent = readFileSync(dstFile, "utf-8");
} catch { continue; }
if (srcContent === dstContent) continue;
// Extract all checked task IDs from the source (project root)
// Pattern: - [x] **T<id>: or - [x] **S<id>: (case-insensitive x)
const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm;
const srcChecked = new Set<string>();
for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]);
if (srcChecked.size === 0) continue;
// Forward-apply: replace [ ] → [x] for any IDs that are checked in src
let updated = dstContent;
let changed = false;
for (const id of srcChecked) {
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const uncheckedRe = new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm");
if (uncheckedRe.test(updated)) {
updated = updated.replace(
new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"),
"$1[x]$2",
);
changed = true;
}
}
if (changed) {
try {
writeFileSync(dstFile, updated, "utf-8");
} catch { /* non-fatal */ }
}
}
// Also forward-merge completed-units.json (set-union)
const srcKeys = join(projectRoot, ".gsd", "completed-units.json");
const dstKeys = join(wtPath, ".gsd", "completed-units.json");
if (existsSync(srcKeys)) {
try {
const src: string[] = JSON.parse(readFileSync(srcKeys, "utf-8"));
let dst: string[] = [];
if (existsSync(dstKeys)) {
try { dst = JSON.parse(readFileSync(dstKeys, "utf-8")); } catch { /* ignore corrupt */ }
}
const merged = [...new Set([...dst, ...src])];
if (merged.length > dst.length) {
mkdirSync(join(wtPath, ".gsd"), { recursive: true });
writeFileSync(dstKeys, JSON.stringify(merged), "utf-8");
}
} catch { /* non-fatal */ }
}
}
export function createAutoWorktree(basePath: string, milestoneId: string): string {
const branch = autoWorktreeBranch(milestoneId);
@ -166,6 +272,18 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
// not always fully synced.
if (!branchExists) {
copyPlanningArtifacts(basePath, info.path);
} else {
// Re-attaching to an existing branch: forward-merge any plan checkpoint
// state from the project root into the worktree (#778).
//
// If auto-mode stopped via crash, the milestone branch HEAD may lag behind
// the project root filesystem because syncStateToProjectRoot() ran after
// task completion but the auto-commit never fired. On restart the worktree
// is re-created from the branch HEAD (which has [ ] for the crashed task),
// causing verifyExpectedArtifact() to return false → stale-key eviction →
// infinite dispatch/skip loop. Reconciling here ensures the worktree sees
// the same [x] state that syncStateToProjectRoot() wrote to the root.
reconcilePlanCheckboxes(basePath, info.path, milestoneId);
}
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets

View file

@ -153,6 +153,64 @@ async function main(): Promise<void> {
// After teardown, originalBase should be null
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
{
// Simulate: T01 [x] was committed to milestone branch, T02 [x] was
// written to project root by syncStateToProjectRoot() but the
// auto-commit crashed before it fired. On restart the worktree is
// re-created from the milestone branch HEAD (T02 still [ ]).
// reconcilePlanCheckboxes should forward-apply T02 [x] from the root.
const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md");
const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01");
const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs");
// Plan on integration branch (project root): T01 [x], T02 [x]
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Write integration-branch plan to git so milestone branch starts from it
run(`git add .`, tempDir);
run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir);
// Create milestone branch with only T01 [x] (simulating crash before T02 commit)
const milestoneBranch = "milestone/M004";
run(`git checkout -b ${milestoneBranch}`, tempDir);
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n",
);
run(`git add .`, tempDir);
run(`git commit -m "milestone: only T01 checked"`, tempDir);
run(`git checkout main`, tempDir);
// Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Create worktree re-attached to existing milestone branch (T02 still [ ] in branch)
const wtPath = createAutoWorktree(tempDir, "M004");
try {
const wtPlanPath = join(wtPath, planRelPath);
assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach");
const wtPlan = read(wtPlanPath, "utf-8");
assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)");
assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]");
assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)");
} finally {
teardownAutoWorktree(tempDir, "M004");
}
}
} finally {
// Always restore cwd and clean up
process.chdir(savedCwd);