refactor: merge auto-worktree-sync into auto-worktree

Consolidate all worktree sync, resource staleness, stale worktree escape,
and stale runtime unit cleanup into auto-worktree.ts. Extract shared
ROOT_STATE_FILES constant and isSamePath helper to eliminate triple
duplication of the rootFiles array and copy-pasted symlink checks.

Replace inline 26-line stale-unit cleanup in auto-start.ts with a call
to cleanStaleRuntimeUnits().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-25 22:47:18 -06:00
parent e12274cca9
commit 8429c0e173
10 changed files with 283 additions and 337 deletions

View file

@ -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 |

View file

@ -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";

View file

@ -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);

View file

@ -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/<MID>/`.
* 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/<hash>/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/<something> — 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/<hash> 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;
}

View file

@ -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/<MID>/`.
* 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/<hash>/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/<something> — 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/<hash> 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)) {

View file

@ -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 {

View file

@ -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` |

View file

@ -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",
);
});

View file

@ -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';

View file

@ -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();