fix: sync worktree state to project root after each unit (#654)

When auto-mode runs in a worktree, .gsd/ metadata (STATE.md, roadmap
checkboxes, slice plans, task summaries) only updates inside the
worktree directory. The project root on main retains stale state.

If auto-mode restarts, startAutoMode() calls deriveState(projectRoot)
which reads the stale .gsd/ from main, sees completed units as
incomplete, and re-dispatches them — causing an infinite loop on
already-finished work.

Add syncStateToProjectRoot() that copies STATE.md and the active
milestone directory from worktree → project root after each unit's
rebuildState + autoCommit. This ensures deriveState(projectRoot) on
restart reads current completion state.

The sync is fully non-fatal (try/catch wrapped). Failure falls back
to existing behavior. Uses cpSync with recursive:true for the
milestone directory tree.
This commit is contained in:
Tom Boucher 2026-03-16 15:13:24 -04:00
parent a90aa0c8d6
commit bdbd3579c9

View file

@ -82,7 +82,7 @@ import {
import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync, cpSync } from "node:fs";
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
import {
autoCommitCurrentBranch,
@ -146,6 +146,45 @@ import {
import { isDbAvailable } from "./gsd-db.js";
import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
// ─── Worktree → Project Root State Sync ───────────────────────────────────────
// When running in an auto-worktree, dispatch state (.gsd/ metadata) diverges
// between the worktree (where work happens) and the project root (where
// startAutoMode reads initial state on restart). Without syncing, restarting
// auto-mode reads stale state from the project root and re-dispatches
// already-completed units.
/**
* Sync dispatch-critical .gsd/ state files from worktree to project root.
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
* Non-fatal sync failure should never block dispatch.
*/
function syncStateToProjectRoot(worktreePath: string, projectRoot: string, milestoneId: string | null): void {
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
if (!milestoneId) return;
const wtGsd = join(worktreePath, ".gsd");
const prGsd = join(projectRoot, ".gsd");
// 1. STATE.md — the quick-glance status used by initial deriveState()
try {
const src = join(wtGsd, "STATE.md");
const dst = join(prGsd, "STATE.md");
if (existsSync(src)) cpSync(src, dst, { force: true });
} catch { /* non-fatal */ }
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
try {
const srcMilestone = join(wtGsd, "milestones", milestoneId);
const dstMilestone = join(prGsd, "milestones", milestoneId);
if (existsSync(srcMilestone)) {
mkdirSync(dstMilestone, { recursive: true });
cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
}
} catch { /* non-fatal */ }
}
// ─── State ────────────────────────────────────────────────────────────────────
let active = false;
@ -1214,6 +1253,17 @@ export async function handleAgentEnd(
// Non-fatal
}
// ── Sync worktree state back to project root ──────────────────────────
// Ensures that if auto-mode restarts, deriveState(projectRoot) reads
// current milestone progress instead of stale pre-worktree state (#654).
if (originalBasePath && originalBasePath !== basePath) {
try {
syncStateToProjectRoot(basePath, originalBasePath, currentMilestoneId);
} catch {
// Non-fatal — stale state is the existing behavior, sync is an improvement
}
}
// ── Rewrite-docs completion: resolve overrides and reset circuit breaker ──
if (currentUnit.type === "rewrite-docs") {
try {