diff --git a/docs-internal/FILE-SYSTEM-MAP.md b/docs-internal/FILE-SYSTEM-MAP.md index dd67d333f..07e253cad 100644 --- a/docs-internal/FILE-SYSTEM-MAP.md +++ b/docs-internal/FILE-SYSTEM-MAP.md @@ -467,10 +467,9 @@ | gsd/index.ts | GSD Workflow | Main GSD extension bootstrap and registration | | gsd/auto.ts | Auto Engine | Automatic workflow execution and loop management | | gsd/auto-dashboard.ts | Auto Engine, Web Mode | Real-time dashboard for auto-run progress | -| gsd/auto-worktree.ts | Auto Engine, Worktree | Automatic worktree creation and branch management | +| gsd/auto-worktree.ts | Auto Engine, Worktree | Worktree lifecycle, state sync, resource staleness, stale escape | | gsd/auto-recovery.ts | Auto Engine | Recovery for crashed/stalled workflows | | gsd/auto-start.ts | Auto Engine | Initialization sequence for automatic execution | -| gsd/auto-worktree-sync.ts | Auto Engine, Worktree | State sync between worktrees and main | | gsd/auto-model-selection.ts | Auto Engine, Model System | Intelligent LLM model routing | | gsd/auto-direct-dispatch.ts | Auto Engine | Direct command dispatching without planning | | gsd/auto-dispatch.ts | Auto Engine | Task queueing and priority-based dispatch | diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 1aa4471ad..77fdf1740 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -33,7 +33,7 @@ import { resolveExpectedArtifactPath, } from "./auto-recovery.js"; import { regenerateIfMissing } from "./workflow-projections.js"; -import { syncStateToProjectRoot } from "./auto-worktree-sync.js"; +import { syncStateToProjectRoot } from "./auto-worktree.js"; import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js"; import { renderPlanCheckboxes } from "./markdown-renderer.js"; import { consumeSignal } from "./session-status-io.js"; diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index e47dc5069..7c0612bc2 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -52,7 +52,7 @@ import { setActiveMilestoneId, } from "./worktree.js"; import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js"; -import { readResourceVersion } from "./auto-worktree-sync.js"; +import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js"; import { initMetrics } from "./metrics.js"; import { initRoutingHistory } from "./routing-history.js"; import { restoreHookState, resetHookState } from "./post-unit-hooks.js"; @@ -258,31 +258,10 @@ export async function bootstrapAutoSession( invalidateAllCaches(); // Clean stale runtime unit files for completed milestones (#887) - try { - const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); - if (existsSync(runtimeUnitsDir)) { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) continue; - const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); - if (!midMatch) continue; - const mid = midMatch[1]; - if (resolveMilestoneFile(base, mid, "SUMMARY")) { - try { - unlinkSync(join(runtimeUnitsDir, file)); - } catch (e) { - debugLog("stale-unit-cleanup-failed", { - file, - error: e instanceof Error ? e.message : String(e), - }); - } - } - } - } - } catch (e) { - debugLog("stale-unit-dir-cleanup-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } + cleanStaleRuntimeUnits( + gsdRoot(base), + (mid) => !!resolveMilestoneFile(base, mid, "SUMMARY"), + ); let state = await deriveState(base); diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts deleted file mode 100644 index 395bb0934..000000000 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Worktree ↔ project root state synchronization for auto-mode. - * - * When auto-mode runs inside a worktree, dispatch-critical state files - * (.gsd/ metadata) diverge 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. - * - * Also contains resource staleness detection and stale worktree escape. - */ - -import { - existsSync, - mkdirSync, - readFileSync, - cpSync, - unlinkSync, - readdirSync, -} from "node:fs"; -import { join, sep as pathSep } from "node:path"; -import { homedir } from "node:os"; -import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; - -const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); - -// ─── Project Root → Worktree Sync ───────────────────────────────────────── - -/** - * Sync milestone artifacts from project root INTO worktree before deriveState. - * Covers the case where the LLM wrote artifacts to the main repo filesystem - * (e.g. via absolute paths) but the worktree has stale data. Also deletes - * gsd.db in the worktree so it rebuilds from fresh disk state (#853). - * Non-fatal — sync failure should never block dispatch. - */ -export function syncProjectRootToWorktree( - projectRoot: string, - worktreePath: string, - milestoneId: string | null, -): void { - if (!worktreePath || !projectRoot || worktreePath === projectRoot) return; - if (!milestoneId) return; - - const prGsd = join(projectRoot, ".gsd"); - const wtGsd = join(worktreePath, ".gsd"); - - // Copy milestone directory from project root to worktree — additive only. - // force:false prevents cpSync from overwriting existing worktree files. - // Without this, worktree-authoritative files (e.g. VALIDATION.md written - // by validate-milestone) get clobbered by stale project root copies, - // causing an infinite re-validation loop (#1886). - safeCopyRecursive( - join(prGsd, "milestones", milestoneId), - join(wtGsd, "milestones", milestoneId), - { force: false }, - ); - - // Forward-sync completed-units.json from project root to worktree. - // Project root is authoritative for completion state after crash recovery; - // without this, the worktree re-dispatches already-completed units (#1886). - safeCopy( - join(prGsd, "completed-units.json"), - join(wtGsd, "completed-units.json"), - { force: true }, - ); - - // Delete worktree gsd.db so it rebuilds from the freshly synced files. - // Stale DB rows are the root cause of the infinite skip loop (#853). - try { - const wtDb = join(wtGsd, "gsd.db"); - if (existsSync(wtDb)) { - unlinkSync(wtDb); - } - } catch { - /* non-fatal */ - } -} - -// ─── Worktree → Project Root Sync ───────────────────────────────────────── - -/** - * 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. - */ -export 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() - safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true }); - - // 2. Milestone directory — ROADMAP, slice PLANs, task summaries - // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes - safeCopyRecursive( - join(wtGsd, "milestones", milestoneId), - join(prGsd, "milestones", milestoneId), - { force: true }, - ); - - // 3. metrics.json — session cost/token tracking (#2313). - // Without this, metrics accumulated in the worktree are invisible from the - // project root and never appear in the dashboard or skill-health reports. - safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true }); - - // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords(). - // Without this, a crash during a unit leaves the runtime record only in the - // worktree. If the next session resolves basePath before worktree re-entry, - // selfHeal can't find or clear the stale record (#769). - safeCopyRecursive( - join(wtGsd, "runtime", "units"), - join(prGsd, "runtime", "units"), - { force: true }, - ); -} - -// ─── Resource Staleness ─────────────────────────────────────────────────── - -/** - * Read the resource version (semver) from the managed-resources manifest. - * Uses gsdVersion instead of syncedAt so that launching a second session - * doesn't falsely trigger staleness (#804). - */ -export function readResourceVersion(): string | null { - const agentDir = - process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent"); - const manifestPath = join(agentDir, "managed-resources.json"); - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - return typeof manifest?.gsdVersion === "string" - ? manifest.gsdVersion - : null; - } catch { - return null; - } -} - -/** - * Check if managed resources have been updated since session start. - * Returns a warning message if stale, null otherwise. - */ -export function checkResourcesStale( - versionOnStart: string | null, -): string | null { - if (versionOnStart === null) return null; - const current = readResourceVersion(); - if (current === null) return null; - if (current !== versionOnStart) { - return "GSD resources were updated since this session started. Restart gsd to load the new code."; - } - return null; -} - -// ─── Stale Worktree Escape ──────────────────────────────────────────────── - -/** - * Detect and escape a stale worktree cwd (#608). - * - * After milestone completion + merge, the worktree directory is removed but - * the process cwd may still point inside `.gsd/worktrees//`. - * When a new session starts, `process.cwd()` is passed as `base` to startAuto - * and all subsequent writes land in the wrong directory. This function detects - * that scenario and chdir back to the project root. - * - * Returns the corrected base path. - */ -export function escapeStaleWorktree(base: string): string { - // Direct layout: /.gsd/worktrees/ - const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; - let idx = base.indexOf(directMarker); - if (idx === -1) { - // Symlink-resolved layout: /.gsd/projects//worktrees/ - const symlinkRe = new RegExp( - `\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`, - ); - const match = base.match(symlinkRe); - if (!match || match.index === undefined) return base; - idx = match.index; - } - - // base is inside .gsd/worktrees/ — extract the project root - const projectRoot = base.slice(0, idx); - - // Guard: If the candidate project root's .gsd IS the user-level ~/.gsd, - // the string-slice heuristic matched the wrong /.gsd/ boundary. This happens - // when .gsd is a symlink into ~/.gsd/projects/ and process.cwd() - // resolved through the symlink. Returning ~ would be catastrophic (#1676). - const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/"); - const gsdHomePath = gsdHome.replaceAll("\\", "/"); - if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) { - // Don't chdir to home — return base unchanged. - // resolveProjectRoot() in worktree.ts has the full git-file-based recovery - // and will be called by the caller (startAuto → projectRoot()). - return base; - } - - try { - process.chdir(projectRoot); - } catch { - // If chdir fails, return the original — caller will handle errors downstream - return base; - } - return projectRoot; -} - -/** - * Clean stale runtime unit files for completed milestones. - * - * After restart, stale runtime/units/*.json from prior milestones can - * cause deriveState to resume the wrong milestone (#887). Removes files - * for milestones that have a SUMMARY (fully complete). - */ -export function cleanStaleRuntimeUnits( - gsdRootPath: string, - hasMilestoneSummary: (mid: string) => boolean, -): number { - const runtimeUnitsDir = join(gsdRootPath, "runtime", "units"); - if (!existsSync(runtimeUnitsDir)) return 0; - - let cleaned = 0; - try { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) continue; - const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); - if (!midMatch) continue; - if (hasMilestoneSummary(midMatch[1])) { - try { - unlinkSync(join(runtimeUnitsDir, file)); - cleaned++; - } catch { - /* non-fatal */ - } - } - } - } catch { - /* non-fatal */ - } - return cleaned; -} diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index e91c67009..7c1e59616 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -17,7 +17,8 @@ import { unlinkSync, lstatSync as lstatSyncFn, } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute, join, sep as pathSep } from "node:path"; +import { homedir } from "node:os"; import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js"; import { reconcileWorktreeDb, @@ -63,6 +64,38 @@ import { nativeIsAncestor, } from "./native-git-bridge.js"; +const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + +// ─── Shared Constants & Helpers ───────────────────────────────────────────── + +/** + * Root-level .gsd/ state files synced between worktree and project root. + * Single source of truth — used by syncGsdStateToWorktree, syncWorktreeStateBack, + * and the dispatch-level sync functions. + */ +const ROOT_STATE_FILES = [ + "DECISIONS.md", + "REQUIREMENTS.md", + "PROJECT.md", + "KNOWLEDGE.md", + "OVERRIDES.md", + "QUEUE.md", + "completed-units.json", + "metrics.json", +] as const; + +/** + * Check if two filesystem paths resolve to the same real location. + * Returns false if either path cannot be resolved (e.g. doesn't exist). + */ +function isSamePath(a: string, b: string): boolean { + try { + return realpathSync(a) === realpathSync(b); + } catch { + return false; + } +} + // ─── Module State ────────────────────────────────────────────────────────── /** Original project root before chdir into auto-worktree. */ @@ -119,6 +152,227 @@ function clearProjectRootStateFiles(basePath: string, milestoneId: string): void } } } + +// ─── Dispatch-Level Sync (project root ↔ worktree) ────────────────────────── + +/** + * Sync milestone artifacts from project root INTO worktree before deriveState. + * Covers the case where the LLM wrote artifacts to the main repo filesystem + * (e.g. via absolute paths) but the worktree has stale data. Also deletes + * gsd.db in the worktree so it rebuilds from fresh disk state (#853). + * Non-fatal — sync failure should never block dispatch. + */ +export function syncProjectRootToWorktree( + projectRoot: string, + worktreePath_: string, + milestoneId: string | null, +): void { + if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return; + if (!milestoneId) return; + + const prGsd = join(projectRoot, ".gsd"); + const wtGsd = join(worktreePath_, ".gsd"); + + // Copy milestone directory from project root to worktree — additive only. + // force:false prevents cpSync from overwriting existing worktree files. + // Without this, worktree-authoritative files (e.g. VALIDATION.md written + // by validate-milestone) get clobbered by stale project root copies, + // causing an infinite re-validation loop (#1886). + safeCopyRecursive( + join(prGsd, "milestones", milestoneId), + join(wtGsd, "milestones", milestoneId), + { force: false }, + ); + + // Forward-sync completed-units.json from project root to worktree. + // Project root is authoritative for completion state after crash recovery; + // without this, the worktree re-dispatches already-completed units (#1886). + safeCopy( + join(prGsd, "completed-units.json"), + join(wtGsd, "completed-units.json"), + { force: true }, + ); + + // Delete worktree gsd.db so it rebuilds from the freshly synced files. + // Stale DB rows are the root cause of the infinite skip loop (#853). + try { + const wtDb = join(wtGsd, "gsd.db"); + if (existsSync(wtDb)) { + unlinkSync(wtDb); + } + } catch { + /* non-fatal */ + } +} + +/** + * 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. + */ +export 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() + safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true }); + + // 2. Milestone directory — ROADMAP, slice PLANs, task summaries + // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes + safeCopyRecursive( + join(wtGsd, "milestones", milestoneId), + join(prGsd, "milestones", milestoneId), + { force: true }, + ); + + // 3. metrics.json — session cost/token tracking (#2313). + // Without this, metrics accumulated in the worktree are invisible from the + // project root and never appear in the dashboard or skill-health reports. + safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true }); + + // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords(). + // Without this, a crash during a unit leaves the runtime record only in the + // worktree. If the next session resolves basePath before worktree re-entry, + // selfHeal can't find or clear the stale record (#769). + safeCopyRecursive( + join(wtGsd, "runtime", "units"), + join(prGsd, "runtime", "units"), + { force: true }, + ); +} + +// ─── Resource Staleness ─────────────────────────────────────────────────── + +/** + * Read the resource version (semver) from the managed-resources manifest. + * Uses gsdVersion instead of syncedAt so that launching a second session + * doesn't falsely trigger staleness (#804). + */ +export function readResourceVersion(): string | null { + const agentDir = + process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent"); + const manifestPath = join(agentDir, "managed-resources.json"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + return typeof manifest?.gsdVersion === "string" + ? manifest.gsdVersion + : null; + } catch { + return null; + } +} + +/** + * Check if managed resources have been updated since session start. + * Returns a warning message if stale, null otherwise. + */ +export function checkResourcesStale( + versionOnStart: string | null, +): string | null { + if (versionOnStart === null) return null; + const current = readResourceVersion(); + if (current === null) return null; + if (current !== versionOnStart) { + return "GSD resources were updated since this session started. Restart gsd to load the new code."; + } + return null; +} + +// ─── Stale Worktree Escape ──────────────────────────────────────────────── + +/** + * Detect and escape a stale worktree cwd (#608). + * + * After milestone completion + merge, the worktree directory is removed but + * the process cwd may still point inside `.gsd/worktrees//`. + * When a new session starts, `process.cwd()` is passed as `base` to startAuto + * and all subsequent writes land in the wrong directory. This function detects + * that scenario and chdir back to the project root. + * + * Returns the corrected base path. + */ +export function escapeStaleWorktree(base: string): string { + // Direct layout: /.gsd/worktrees/ + const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; + let idx = base.indexOf(directMarker); + if (idx === -1) { + // Symlink-resolved layout: /.gsd/projects//worktrees/ + const symlinkRe = new RegExp( + `\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`, + ); + const match = base.match(symlinkRe); + if (!match || match.index === undefined) return base; + idx = match.index; + } + + // base is inside .gsd/worktrees/ — extract the project root + const projectRoot = base.slice(0, idx); + + // Guard: If the candidate project root's .gsd IS the user-level ~/.gsd, + // the string-slice heuristic matched the wrong /.gsd/ boundary. This happens + // when .gsd is a symlink into ~/.gsd/projects/ and process.cwd() + // resolved through the symlink. Returning ~ would be catastrophic (#1676). + const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/"); + const gsdHomePath = gsdHome.replaceAll("\\", "/"); + if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) { + // Don't chdir to home — return base unchanged. + // resolveProjectRoot() in worktree.ts has the full git-file-based recovery + // and will be called by the caller (startAuto → projectRoot()). + return base; + } + + try { + process.chdir(projectRoot); + } catch { + // If chdir fails, return the original — caller will handle errors downstream + return base; + } + return projectRoot; +} + +/** + * Clean stale runtime unit files for completed milestones. + * + * After restart, stale runtime/units/*.json from prior milestones can + * cause deriveState to resume the wrong milestone (#887). Removes files + * for milestones that have a SUMMARY (fully complete). + */ +export function cleanStaleRuntimeUnits( + gsdRootPath: string, + hasMilestoneSummary: (mid: string) => boolean, +): number { + const runtimeUnitsDir = join(gsdRootPath, "runtime", "units"); + if (!existsSync(runtimeUnitsDir)) return 0; + + let cleaned = 0; + try { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); + if (!midMatch) continue; + if (hasMilestoneSummary(midMatch[1])) { + try { + unlinkSync(join(runtimeUnitsDir, file)); + cleaned++; + } catch { + /* non-fatal */ + } + } + } + } catch { + /* non-fatal */ + } + return cleaned; +} + // ─── Worktree ↔ Main Repo Sync (#1311) ────────────────────────────────────── /** @@ -144,28 +398,12 @@ export function syncGsdStateToWorktree( const synced: string[] = []; // If both resolve to the same directory (symlink), no sync needed - try { - const mainResolved = realpathSync(mainGsd); - const wtResolved = realpathSync(wtGsd); - if (mainResolved === wtResolved) return { synced }; - } catch { - // Can't resolve — proceed with sync as a safety measure - } + if (isSamePath(mainGsd, wtGsd)) return { synced }; if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced }; // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.) - const rootFiles = [ - "DECISIONS.md", - "REQUIREMENTS.md", - "PROJECT.md", - "KNOWLEDGE.md", - "OVERRIDES.md", - "QUEUE.md", - "completed-units.json", - "metrics.json", - ]; - for (const f of rootFiles) { + for (const f of ROOT_STATE_FILES) { const src = join(mainGsd, f); const dst = join(wtGsd, f); if (existsSync(src) && !existsSync(dst)) { @@ -298,13 +536,7 @@ export function syncWorktreeStateBack( const synced: string[] = []; // If both resolve to the same directory (symlink), no sync needed - try { - const mainResolved = realpathSync(mainGsd); - const wtResolved = realpathSync(wtGsd); - if (mainResolved === wtResolved) return { synced }; - } catch { - // Can't resolve — proceed with sync - } + if (isSamePath(mainGsd, wtGsd)) return { synced }; if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced }; @@ -330,17 +562,7 @@ export function syncWorktreeStateBack( // Also includes QUEUE.md, completed-units.json, and metrics.json which are // written during milestone closeout and lost on teardown without explicit sync // (#1787, #2313). - const rootFiles = [ - "DECISIONS.md", - "REQUIREMENTS.md", - "PROJECT.md", - "KNOWLEDGE.md", - "OVERRIDES.md", - "QUEUE.md", - "completed-units.json", - "metrics.json", - ]; - for (const f of rootFiles) { + for (const f of ROOT_STATE_FILES) { const src = join(wtGsd, f); const dst = join(mainGsd, f); if (existsSync(src)) { diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 062715bbd..8c696bd42 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -76,13 +76,6 @@ import { import { closeoutUnit } from "./auto-unit-closeout.js"; import { recoverTimedOutUnit } from "./auto-timeout-recovery.js"; import { selectAndApplyModel, resolveModelId } from "./auto-model-selection.js"; -import { - syncProjectRootToWorktree, - syncStateToProjectRoot, - readResourceVersion, - checkResourcesStale, - escapeStaleWorktree, -} from "./auto-worktree-sync.js"; import { resetRoutingHistory, recordOutcome } from "./routing-history.js"; import { checkPostUnitHooks, @@ -143,6 +136,11 @@ import { mergeMilestoneToMain, autoWorktreeBranch, syncWorktreeStateBack, + syncProjectRootToWorktree, + syncStateToProjectRoot, + readResourceVersion, + checkResourcesStale, + escapeStaleWorktree, } from "./auto-worktree.js"; import { pruneQueueOrder } from "./queue-order.js"; @@ -190,8 +188,6 @@ import { } from "./worktree-resolver.js"; import { reorderForCaching } from "./prompt-ordering.js"; -// Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts - // ─── Session State ───────────────────────────────────────────────────────── import { diff --git a/src/resources/extensions/gsd/prompts/forensics.md b/src/resources/extensions/gsd/prompts/forensics.md index 9112a773f..0ea3ce71c 100644 --- a/src/resources/extensions/gsd/prompts/forensics.md +++ b/src/resources/extensions/gsd/prompts/forensics.md @@ -16,7 +16,7 @@ GSD extension source code is at: `{{gsdSourceDir}}` | Domain | Files | |--------|-------| -| **Auto-mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `auto-worktree-sync.ts` `auto-model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` | +| **Auto-mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `auto-model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` | | **State & persistence** | `state.ts` `types.ts` `files.ts` `paths.ts` `json-persistence.ts` `atomic-write.ts` | | **Forensics & recovery** | `forensics.ts` `session-forensics.ts` `crash-recovery.ts` `session-lock.ts` | | **Metrics & telemetry** | `metrics.ts` `skill-telemetry.ts` `token-counter.ts` | diff --git a/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts b/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts index 4c451bece..46da65fa6 100644 --- a/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts +++ b/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts @@ -48,35 +48,32 @@ test("#2313: completed-units.json should not be blindly wiped to [] on milestone // ─── Bug 2: metrics.json should be in the sync file lists ────────────────── test("#2313: syncStateToProjectRoot should sync metrics.json", () => { - const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree-sync.ts"); + const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts"); const syncSrc = readFileSync(syncSrcPath, "utf-8"); // syncStateToProjectRoot should copy metrics.json from worktree to project root assert.ok( syncSrc.includes("metrics.json"), - "auto-worktree-sync.ts should reference metrics.json for sync", + "auto-worktree.ts should reference metrics.json for sync", ); }); -test("#2313: syncWorktreeStateBack should include metrics.json in root files list", () => { +test("#2313: syncWorktreeStateBack should include metrics.json in ROOT_STATE_FILES", () => { const autoWorktreeSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts"); const autoWorktreeSrc = readFileSync(autoWorktreeSrcPath, "utf-8"); - // Find the rootFiles array in syncWorktreeStateBack - const syncBackIdx = autoWorktreeSrc.indexOf("syncWorktreeStateBack"); - assert.ok(syncBackIdx !== -1, "syncWorktreeStateBack exists"); + // Find the ROOT_STATE_FILES constant (single source of truth for both sync directions) + const constIdx = autoWorktreeSrc.indexOf("ROOT_STATE_FILES"); + assert.ok(constIdx !== -1, "ROOT_STATE_FILES constant exists"); - const rootFilesIdx = autoWorktreeSrc.indexOf("rootFiles", syncBackIdx); - assert.ok(rootFilesIdx !== -1, "rootFiles list exists in syncWorktreeStateBack"); - - // Get the rootFiles array content - const arrayStart = autoWorktreeSrc.indexOf("[", rootFilesIdx); + // Get the array content + const arrayStart = autoWorktreeSrc.indexOf("[", constIdx); const arrayEnd = autoWorktreeSrc.indexOf("]", arrayStart); const rootFilesBlock = autoWorktreeSrc.slice(arrayStart, arrayEnd); assert.ok( rootFilesBlock.includes("metrics.json"), - "metrics.json should be in syncWorktreeStateBack rootFiles list", + "metrics.json should be in ROOT_STATE_FILES list", ); }); diff --git a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts index 0df83dfd2..94cebb383 100644 --- a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts @@ -27,7 +27,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts'; +import { syncProjectRootToWorktree } from '../auto-worktree.ts'; import { syncGsdStateToWorktree, syncWorktreeStateBack } from '../auto-worktree.ts'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts index 211c87d8d..fd297b5ee 100644 --- a/src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts @@ -29,7 +29,7 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; -import { syncProjectRootToWorktree } from "../auto-worktree-sync.ts"; +import { syncProjectRootToWorktree } from "../auto-worktree.ts"; import { createTestContext } from "./test-helpers.ts"; const { assertTrue, assertEq, report } = createTestContext();