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:
parent
e12274cca9
commit
8429c0e173
10 changed files with 283 additions and 337 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue