fix: make forensics worktree-aware to prevent stale root misdiagnosis
When auto-mode runs in an auto-worktree, activity logs are written to `.gsd/worktrees/<MID>/.gsd/activity/` while forensics only scanned `.gsd/activity/` at the project root. This caused forensics to report stale failures from the root while the worktree had already produced the correct artifacts and advanced to execution. Changes: forensics.ts: - scanActivityLogs() now accepts activeMilestone and scans both the worktree activity dir (if an auto-worktree exists) and the root dir - Results are merged and sorted by mtime so the most recent traces from either source appear first - detectMissingArtifacts() checks both root and worktree paths before reporting a missing artifact, preventing false positives - ForensicReport now includes activeWorktree field for visibility - Saved report and prompt output include worktree context session-forensics.ts: - getDeepDiagnostic() now checks the worktree activity dir first by reading the active milestone ID from STATE.md (synchronous, no async deriveState dependency) - Falls back to root activity dir when no worktree is found - Added readActiveMilestoneId() helper for sync milestone detection Closes #724
This commit is contained in:
parent
51cf029c96
commit
b19357dc84
2 changed files with 124 additions and 43 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue