Merge pull request #767 from trek-e/fix/759-auto-mode-stale-state-loop

fix: prevent stale state loop on auto-mode restart with existing worktree (#759)
This commit is contained in:
TÂCHES 2026-03-16 20:29:43 -06:00 committed by GitHub
commit 392b75a4db
3 changed files with 23 additions and 42 deletions

View file

@ -158,7 +158,15 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
// Planning artifacts may be untracked if the project's .gitignore had a
// blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops
// on plan-slice because the plan file doesn't exist in the worktree.
copyPlanningArtifacts(basePath, info.path);
//
// IMPORTANT: Skip when re-attaching to an existing branch (#759).
// The branch checkout already has committed artifacts with correct state
// (e.g. [x] for completed slices). Copying from the project root would
// overwrite them with stale data ([ ] checkboxes) because the root is
// not always fully synced.
if (!branchExists) {
copyPlanningArtifacts(basePath, info.path);
}
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
const hookError = runWorktreePostCreateHook(basePath, info.path);

View file

@ -32,7 +32,6 @@ import {
import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
import { isDbAvailable, _getAdapter } from './gsd-db.js';
import { join, resolve } from 'path';
import { debugCount, debugTime } from './debug-logger.js';
@ -149,30 +148,12 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
const fileContentCache = new Map<string, string>();
const gsdDir = gsdRoot(basePath);
// ── DB-first content loading ──
// When the DB is available, load artifact content from the artifacts table
// (indexed SELECT instead of O(N) file I/O). Falls back to native Rust batch
// parser, which in turn falls back to sequential JS reads via cachedLoadFile.
let dbContentLoaded = false;
if (isDbAvailable()) {
const adapter = _getAdapter();
if (adapter) {
try {
const rows = adapter.prepare('SELECT path, full_content FROM artifacts').all();
for (const row of rows) {
const relPath = (row as Record<string, unknown>)['path'] as string;
const content = (row as Record<string, unknown>)['full_content'] as string;
const absPath = resolve(gsdDir, relPath);
fileContentCache.set(absPath, content);
}
dbContentLoaded = rows.length > 0;
} catch {
// DB query failed — fall through to native batch parse
}
}
}
if (!dbContentLoaded) {
// NOTE: We intentionally do NOT load from the SQLite DB here (#759).
// The DB's artifacts table is populated once during migrateFromMarkdown
// and is never updated when files change on disk (e.g. roadmap [x] updates,
// plan checkbox changes). Using stale DB content causes deriveState to
// return incorrect phase/slice state, leading to infinite skip loops.
// The native Rust batch parser is fast enough for state derivation.
const batchFiles = nativeBatchParseGsdFiles(gsdDir);
if (batchFiles) {
for (const f of batchFiles) {
@ -180,7 +161,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
fileContentCache.set(absPath, f.rawContent);
}
}
}
/**
* Load file content from batch cache first, falling back to disk read.

View file

@ -248,31 +248,24 @@ async function main(): Promise<void> {
}
}
// ─── Test 5: Requirements counting from DB content ───────────────────
console.log('\n=== derive-state-db: requirements from DB content ===');
// ─── Test 5: Requirements counting from disk (DB no longer used for content)
console.log('\n=== derive-state-db: requirements from disk content ===');
{
const base = createFixtureBase();
try {
// Write minimal milestone dir (needed for milestone discovery)
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
// Do NOT write REQUIREMENTS.md to disk — only in DB
openDatabase(':memory:');
insertArtifactRow('REQUIREMENTS.md', REQUIREMENTS_CONTENT, {
artifact_type: 'requirements',
});
// Write REQUIREMENTS.md to disk (DB content is no longer used by deriveState)
writeFile(base, 'REQUIREMENTS.md', REQUIREMENTS_CONTENT);
invalidateStateCache();
const state = await deriveState(base);
// Requirements should come from DB
assertEq(state.requirements?.active, 2, 'req-from-db: requirements.active = 2');
assertEq(state.requirements?.validated, 1, 'req-from-db: requirements.validated = 1');
assertEq(state.requirements?.total, 3, 'req-from-db: requirements.total = 3');
closeDatabase();
// Requirements should come from disk
assertEq(state.requirements?.active, 2, 'req-from-disk: requirements.active = 2');
assertEq(state.requirements?.validated, 1, 'req-from-disk: requirements.validated = 1');
assertEq(state.requirements?.total, 3, 'req-from-disk: requirements.total = 3');
} finally {
closeDatabase();
cleanup(base);
}
}