Merge pull request #729 from jeremymcs/fix/724-forensics-worktree-awareness

fix: make forensics worktree-aware to prevent stale root misdiagnosis (#724)
This commit is contained in:
TÂCHES 2026-03-16 18:34:46 -06:00 committed by GitHub
commit d8ebe1300a
2 changed files with 124 additions and 43 deletions

View file

@ -27,6 +27,7 @@ import { isAutoActive } from "./auto.js";
import { loadPrompt } from "./prompt-loader.js";
import { gsdRoot } from "./paths.js";
import { formatDuration } from "./history.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -54,6 +55,7 @@ interface ForensicReport {
basePath: string;
activeMilestone: string | null;
activeSlice: string | null;
activeWorktree: string | null;
unitTraces: UnitTrace[];
metrics: MetricsLedger | null;
completedKeys: string[];
@ -143,8 +145,11 @@ async function buildForensicReport(basePath: string): Promise<ForensicReport> {
activeSlice = state.activeSlice?.id ?? null;
} catch { /* state derivation failure is non-fatal */ }
// 2. Scan activity logs (last 5)
const unitTraces = scanActivityLogs(basePath);
// 1b. Check for active auto-worktree
const activeWorktree = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null;
// 2. Scan activity logs (last 5) — worktree-aware
const unitTraces = scanActivityLogs(basePath, activeMilestone);
// 3. Load metrics
const metrics = loadLedgerFromDisk(basePath);
@ -191,7 +196,7 @@ async function buildForensicReport(basePath: string): Promise<ForensicReport> {
if (metrics?.units) detectStuckLoops(metrics.units, anomalies);
if (metrics?.units) detectCostSpikes(metrics.units, anomalies);
detectTimeouts(unitTraces, anomalies);
detectMissingArtifacts(completedKeys, basePath, anomalies);
detectMissingArtifacts(completedKeys, basePath, activeMilestone, anomalies);
detectCrash(crashLock, anomalies);
detectDoctorIssues(doctorIssues, anomalies);
detectErrorTraces(unitTraces, anomalies);
@ -202,6 +207,7 @@ async function buildForensicReport(basePath: string): Promise<ForensicReport> {
basePath,
activeMilestone,
activeSlice,
activeWorktree: activeWorktree ? relative(basePath, activeWorktree) : null,
unitTraces,
metrics,
completedKeys,
@ -216,48 +222,78 @@ async function buildForensicReport(basePath: string): Promise<ForensicReport> {
const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/;
function scanActivityLogs(basePath: string): UnitTrace[] {
const activityDir = join(gsdRoot(basePath), "activity");
if (!existsSync(activityDir)) return [];
function scanActivityLogs(basePath: string, activeMilestone?: string | null): UnitTrace[] {
const activityDirs = resolveActivityDirs(basePath, activeMilestone);
const allTraces: UnitTrace[] = [];
const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort();
const lastFiles = files.slice(-5);
const traces: UnitTrace[] = [];
for (const activityDir of activityDirs) {
if (!existsSync(activityDir)) continue;
for (const file of lastFiles) {
const match = ACTIVITY_FILENAME_RE.exec(file);
if (!match) continue;
const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort();
const lastFiles = files.slice(-5);
const seq = parseInt(match[1]!, 10);
const unitType = match[2]!;
const unitId = match[3]!;
const filePath = join(activityDir, file);
for (const file of lastFiles) {
const match = ACTIVITY_FILENAME_RE.exec(file);
if (!match) continue;
let entries: unknown[] = [];
const nativeResult = nativeParseJsonlTail(filePath, MAX_JSONL_BYTES);
if (nativeResult) {
entries = nativeResult.entries;
} else {
try {
const raw = readFileSync(filePath, "utf-8");
entries = parseJSONL(raw);
} catch { continue; }
const seq = parseInt(match[1]!, 10);
const unitType = match[2]!;
const unitId = match[3]!;
const filePath = join(activityDir, file);
let entries: unknown[] = [];
const nativeResult = nativeParseJsonlTail(filePath, MAX_JSONL_BYTES);
if (nativeResult) {
entries = nativeResult.entries;
} else {
try {
const raw = readFileSync(filePath, "utf-8");
entries = parseJSONL(raw);
} catch { continue; }
}
const trace = extractTrace(entries);
const stat = statSync(filePath, { throwIfNoEntry: false });
allTraces.push({
file: activityDirs.length > 1 ? `[${relative(basePath, activityDir)}] ${file}` : file,
unitType,
unitId,
seq,
trace,
mtime: stat?.mtimeMs ?? 0,
});
}
const trace = extractTrace(entries);
const stat = statSync(filePath, { throwIfNoEntry: false });
traces.push({
file,
unitType,
unitId,
seq,
trace,
mtime: stat?.mtimeMs ?? 0,
});
}
return traces.sort((a, b) => b.seq - a.seq);
// Sort by mtime descending so the most recent traces (regardless of source) come first
return allTraces.sort((a, b) => b.mtime - a.mtime).slice(0, 5);
}
/**
* Resolve activity directories to scan for forensics.
* If an active auto-worktree exists for the milestone, its activity dir
* is included first (preferred) so stale root logs don't mask worktree progress.
*/
function resolveActivityDirs(basePath: string, activeMilestone?: string | null): string[] {
const dirs: string[] = [];
// Check for active auto-worktree activity logs
if (activeMilestone) {
const wtPath = getAutoWorktreePath(basePath, activeMilestone);
if (wtPath) {
const wtActivityDir = join(wtPath, ".gsd", "activity");
if (existsSync(wtActivityDir)) {
dirs.push(wtActivityDir);
}
}
}
// Always include root activity logs
const rootActivityDir = join(gsdRoot(basePath), "activity");
dirs.push(rootActivityDir);
return dirs;
}
// ─── Completed Keys Loader ────────────────────────────────────────────────────
@ -336,21 +372,27 @@ function detectTimeouts(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void
}
}
function detectMissingArtifacts(completedKeys: string[], basePath: string, anomalies: ForensicAnomaly[]): void {
function detectMissingArtifacts(completedKeys: string[], basePath: string, activeMilestone: string | null, anomalies: ForensicAnomaly[]): void {
// Also check the worktree path for artifacts — they may exist there but not at root
const wtBasePath = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null;
for (const key of completedKeys) {
const slashIdx = key.indexOf("/");
if (slashIdx === -1) continue;
const unitType = key.slice(0, slashIdx);
const unitId = key.slice(slashIdx + 1);
if (!verifyExpectedArtifact(unitType, unitId, basePath)) {
const rootHasArtifact = verifyExpectedArtifact(unitType, unitId, basePath);
const wtHasArtifact = wtBasePath ? verifyExpectedArtifact(unitType, unitId, wtBasePath) : false;
if (!rootHasArtifact && !wtHasArtifact) {
anomalies.push({
type: "missing-artifact",
severity: "error",
unitType,
unitId,
summary: `Completed key ${key} but artifact missing or invalid`,
details: `The unit is recorded as completed but verifyExpectedArtifact() returns false. The completion state is stale.`,
details: `The unit is recorded as completed but verifyExpectedArtifact() returns false at both project root and worktree. The completion state is stale.`,
});
}
}
@ -416,6 +458,7 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes
`**GSD Version:** ${report.gsdVersion}`,
`**Active Milestone:** ${report.activeMilestone ?? "none"}`,
`**Active Slice:** ${report.activeSlice ?? "none"}`,
`**Active Worktree:** ${report.activeWorktree ?? "none"}`,
``,
`## Problem Description`,
``,
@ -559,6 +602,10 @@ function formatReportForPrompt(report: ForensicReport): string {
sections.push(`### GSD Version: ${report.gsdVersion}`);
sections.push(`### Active Milestone: ${report.activeMilestone ?? "none"}`);
sections.push(`### Active Slice: ${report.activeSlice ?? "none"}`);
if (report.activeWorktree) {
sections.push(`### Active Worktree: ${report.activeWorktree}`);
sections.push(`Note: Activity logs were scanned from both the worktree and the project root. Worktree logs take priority.`);
}
let result = sections.join("\n");
if (result.length > MAX_BYTES) {

View file

@ -22,6 +22,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { nativeParseJsonlTail } from "./native-parser-bridge.js";
import { nativeWorkingTreeStatus, nativeDiffStat } from "./native-git-bridge.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -296,12 +297,45 @@ export function synthesizeCrashRecovery(
* Replaces the old shallow getLastActivityDiagnostic().
*/
export function getDeepDiagnostic(basePath: string): string | null {
const activityDir = join(basePath, ".gsd", "activity");
const trace = readLastActivityLog(activityDir);
// Try worktree activity logs first if an auto-worktree is active
let trace: ExecutionTrace | null = null;
try {
const mid = readActiveMilestoneId(basePath);
if (mid) {
const wtPath = getAutoWorktreePath(basePath, mid);
if (wtPath) {
const wtActivityDir = join(wtPath, ".gsd", "activity");
trace = readLastActivityLog(wtActivityDir);
}
}
} catch { /* non-fatal — fall through to root */ }
// Fall back to root activity logs
if (!trace || trace.toolCallCount === 0) {
const activityDir = join(basePath, ".gsd", "activity");
trace = readLastActivityLog(activityDir);
}
if (!trace || trace.toolCallCount === 0) return null;
return formatTraceSummary(trace);
}
/**
* Read the active milestone ID directly from STATE.md without async deriveState().
* Looks for `**Active Milestone:** M001` pattern.
*/
function readActiveMilestoneId(basePath: string): string | null {
try {
const statePath = join(basePath, ".gsd", "STATE.md");
if (!existsSync(statePath)) return null;
const content = readFileSync(statePath, "utf-8");
const match = /\*\*Active Milestone:\*\*\s*(\S+)/i.exec(content);
return match?.[1] ?? null;
} catch {
return null;
}
}
// ─── Formatting ───────────────────────────────────────────────────────────────
function formatRecoveryPrompt(