Merge pull request #2547 from gsd-build/copilot/update-gsd-forensics-logs-and-journal
Enhance /gsd forensics with journal and activity log awareness
This commit is contained in:
commit
9cc993f21c
6 changed files with 557 additions and 7 deletions
|
|
@ -37,7 +37,7 @@ import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./comm
|
|||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ForensicAnomaly {
|
||||
type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace";
|
||||
type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure";
|
||||
severity: "info" | "warning" | "error";
|
||||
unitType?: string;
|
||||
unitId?: string;
|
||||
|
|
@ -54,6 +54,37 @@ interface UnitTrace {
|
|||
mtime: number;
|
||||
}
|
||||
|
||||
/** Summary of .gsd/activity/ directory metadata. */
|
||||
interface ActivityLogMeta {
|
||||
fileCount: number;
|
||||
totalSizeBytes: number;
|
||||
oldestFile: string | null;
|
||||
newestFile: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of .gsd/journal/ data for forensic investigation.
|
||||
*
|
||||
* To avoid loading huge journal histories into memory, only the most recent
|
||||
* daily files are fully parsed. Older files are line-counted for totals.
|
||||
* Event counts and flow IDs reflect only recent files.
|
||||
*/
|
||||
interface JournalSummary {
|
||||
/** Total journal entries across all files (recent parsed + older line-counted) */
|
||||
totalEntries: number;
|
||||
/** Distinct flow IDs from recent files (each = one auto-mode iteration) */
|
||||
flowCount: number;
|
||||
/** Event counts by type (from recent files only) */
|
||||
eventCounts: Record<string, number>;
|
||||
/** Most recent journal entries (last 20) for context */
|
||||
recentEvents: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[];
|
||||
/** Date range of journal data */
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
/** Daily file count */
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface ForensicReport {
|
||||
gsdVersion: string;
|
||||
timestamp: string;
|
||||
|
|
@ -68,6 +99,8 @@ interface ForensicReport {
|
|||
doctorIssues: DoctorIssue[];
|
||||
anomalies: ForensicAnomaly[];
|
||||
recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
|
||||
journalSummary: JournalSummary | null;
|
||||
activityLogMeta: ActivityLogMeta | null;
|
||||
}
|
||||
|
||||
// ─── Duplicate Detection ──────────────────────────────────────────────────────
|
||||
|
|
@ -276,7 +309,13 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
|
|||
// from import.meta.url would resolve to ~/package.json (wrong on every system).
|
||||
const gsdVersion = process.env.GSD_VERSION || "unknown";
|
||||
|
||||
// 9. Run anomaly detectors
|
||||
// 9. Scan journal for flow timeline and structured events
|
||||
const journalSummary = scanJournalForForensics(basePath);
|
||||
|
||||
// 10. Gather activity log directory metadata
|
||||
const activityLogMeta = gatherActivityLogMeta(basePath, activeMilestone);
|
||||
|
||||
// 11. Run anomaly detectors
|
||||
if (metrics?.units) detectStuckLoops(metrics.units, anomalies);
|
||||
if (metrics?.units) detectCostSpikes(metrics.units, anomalies);
|
||||
detectTimeouts(unitTraces, anomalies);
|
||||
|
|
@ -284,6 +323,7 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
|
|||
detectCrash(crashLock, anomalies);
|
||||
detectDoctorIssues(doctorIssues, anomalies);
|
||||
detectErrorTraces(unitTraces, anomalies);
|
||||
detectJournalAnomalies(journalSummary, anomalies);
|
||||
|
||||
return {
|
||||
gsdVersion,
|
||||
|
|
@ -299,6 +339,8 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
|
|||
doctorIssues,
|
||||
anomalies,
|
||||
recentUnits,
|
||||
journalSummary,
|
||||
activityLogMeta,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +348,9 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
|
|||
|
||||
const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/;
|
||||
|
||||
/** Threshold below which iteration cadence is considered rapid (thrashing). */
|
||||
const RAPID_ITERATION_THRESHOLD_MS = 5000;
|
||||
|
||||
function scanActivityLogs(basePath: string, activeMilestone?: string | null): UnitTrace[] {
|
||||
const activityDirs = resolveActivityDirs(basePath, activeMilestone);
|
||||
const allTraces: UnitTrace[] = [];
|
||||
|
|
@ -380,6 +425,154 @@ function resolveActivityDirs(basePath: string, activeMilestone?: string | null):
|
|||
return dirs;
|
||||
}
|
||||
|
||||
// ─── Journal Scanner ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Max recent journal files to fully parse for event counts and recent events.
|
||||
* Older files are line-counted only to avoid loading huge amounts of data.
|
||||
*/
|
||||
const MAX_JOURNAL_RECENT_FILES = 3;
|
||||
|
||||
/** Max recent events to extract for the forensic report timeline. */
|
||||
const MAX_JOURNAL_RECENT_EVENTS = 20;
|
||||
|
||||
/**
|
||||
* Intelligently scan journal files for forensic summary.
|
||||
*
|
||||
* Journal files can be huge (thousands of JSONL entries over weeks of auto-mode).
|
||||
* Instead of loading all entries into memory:
|
||||
* - Only fully parse the most recent N daily files (event counts, flow tracking)
|
||||
* - Line-count older files for approximate totals (no JSON parsing)
|
||||
* - Extract only the last 20 events for the timeline
|
||||
*/
|
||||
function scanJournalForForensics(basePath: string): JournalSummary | null {
|
||||
try {
|
||||
const journalDir = join(gsdRoot(basePath), "journal");
|
||||
if (!existsSync(journalDir)) return null;
|
||||
|
||||
const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Split into recent (fully parsed) and older (line-counted only)
|
||||
const recentFiles = files.slice(-MAX_JOURNAL_RECENT_FILES);
|
||||
const olderFiles = files.slice(0, -MAX_JOURNAL_RECENT_FILES);
|
||||
|
||||
// Line-count older files without parsing — avoids loading megabytes of JSON
|
||||
let olderEntryCount = 0;
|
||||
let oldestEntry: string | null = null;
|
||||
for (const file of olderFiles) {
|
||||
try {
|
||||
const raw = readFileSync(join(journalDir, file), "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
olderEntryCount++;
|
||||
// Extract only the timestamp from the first non-empty line of the oldest file
|
||||
if (!oldestEntry) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { ts?: string };
|
||||
if (parsed.ts) oldestEntry = parsed.ts;
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
}
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
|
||||
// Fully parse recent files for event counts and timeline
|
||||
const eventCounts: Record<string, number> = {};
|
||||
const flowIds = new Set<string>();
|
||||
const recentParsedEntries: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[] = [];
|
||||
let recentEntryCount = 0;
|
||||
|
||||
for (const file of recentFiles) {
|
||||
try {
|
||||
const raw = readFileSync(join(journalDir, file), "utf-8");
|
||||
for (const line of raw.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as { ts: string; flowId: string; eventType: string; rule?: string; data?: Record<string, unknown> };
|
||||
recentEntryCount++;
|
||||
eventCounts[entry.eventType] = (eventCounts[entry.eventType] ?? 0) + 1;
|
||||
flowIds.add(entry.flowId);
|
||||
|
||||
if (!oldestEntry) oldestEntry = entry.ts;
|
||||
|
||||
// Keep a rolling window of last N events — avoids accumulating unbounded arrays
|
||||
recentParsedEntries.push({
|
||||
ts: entry.ts,
|
||||
flowId: entry.flowId,
|
||||
eventType: entry.eventType,
|
||||
rule: entry.rule,
|
||||
unitId: entry.data?.unitId as string | undefined,
|
||||
});
|
||||
if (recentParsedEntries.length > MAX_JOURNAL_RECENT_EVENTS) {
|
||||
recentParsedEntries.shift();
|
||||
}
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
|
||||
const totalEntries = olderEntryCount + recentEntryCount;
|
||||
if (totalEntries === 0) return null;
|
||||
|
||||
const newestEntry = recentParsedEntries.length > 0
|
||||
? recentParsedEntries[recentParsedEntries.length - 1]!.ts
|
||||
: null;
|
||||
|
||||
return {
|
||||
totalEntries,
|
||||
flowCount: flowIds.size,
|
||||
eventCounts,
|
||||
recentEvents: recentParsedEntries,
|
||||
oldestEntry,
|
||||
newestEntry,
|
||||
fileCount: files.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Activity Log Metadata ────────────────────────────────────────────────────
|
||||
|
||||
function gatherActivityLogMeta(basePath: string, activeMilestone?: string | null): ActivityLogMeta | null {
|
||||
try {
|
||||
const activityDirs = resolveActivityDirs(basePath, activeMilestone);
|
||||
let fileCount = 0;
|
||||
let totalSizeBytes = 0;
|
||||
let oldestFile: string | null = null;
|
||||
let newestFile: string | null = null;
|
||||
let oldestMtime = Infinity;
|
||||
let newestMtime = 0;
|
||||
|
||||
for (const activityDir of activityDirs) {
|
||||
if (!existsSync(activityDir)) continue;
|
||||
const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl"));
|
||||
for (const file of files) {
|
||||
const filePath = join(activityDir, file);
|
||||
const stat = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stat) continue;
|
||||
fileCount++;
|
||||
totalSizeBytes += stat.size;
|
||||
if (stat.mtimeMs < oldestMtime) {
|
||||
oldestMtime = stat.mtimeMs;
|
||||
oldestFile = file;
|
||||
}
|
||||
if (stat.mtimeMs > newestMtime) {
|
||||
newestMtime = stat.mtimeMs;
|
||||
newestFile = file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileCount === 0) return null;
|
||||
return { fileCount, totalSizeBytes, oldestFile, newestFile };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Completed Keys Loader ────────────────────────────────────────────────────
|
||||
|
||||
function loadCompletedKeys(basePath: string): string[] {
|
||||
|
|
@ -524,6 +717,66 @@ function detectErrorTraces(traces: UnitTrace[], anomalies: ForensicAnomaly[]): v
|
|||
}
|
||||
}
|
||||
|
||||
function detectJournalAnomalies(journal: JournalSummary | null, anomalies: ForensicAnomaly[]): void {
|
||||
if (!journal) return;
|
||||
|
||||
// Detect stuck-detected events from the journal
|
||||
const stuckCount = journal.eventCounts["stuck-detected"] ?? 0;
|
||||
if (stuckCount > 0) {
|
||||
anomalies.push({
|
||||
type: "journal-stuck",
|
||||
severity: stuckCount >= 3 ? "error" : "warning",
|
||||
summary: `Journal recorded ${stuckCount} stuck-detected event(s)`,
|
||||
details: `The auto-mode loop detected it was stuck ${stuckCount} time(s). Check journal events for flow IDs and causal chains to trace the root cause.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Detect guard-block events (dispatch was blocked by a guard)
|
||||
const guardCount = journal.eventCounts["guard-block"] ?? 0;
|
||||
if (guardCount > 0) {
|
||||
anomalies.push({
|
||||
type: "journal-guard-block",
|
||||
severity: guardCount >= 5 ? "warning" : "info",
|
||||
summary: `Journal recorded ${guardCount} guard-block event(s)`,
|
||||
details: `Dispatch was blocked by a guard condition ${guardCount} time(s). This may indicate a persistent blocking condition preventing progress.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Detect rapid iterations (many flows in short time = likely thrashing)
|
||||
if (journal.flowCount > 0 && journal.oldestEntry && journal.newestEntry) {
|
||||
const oldest = new Date(journal.oldestEntry).getTime();
|
||||
const newest = new Date(journal.newestEntry).getTime();
|
||||
const spanMs = newest - oldest;
|
||||
if (spanMs > 0 && journal.flowCount > 10) {
|
||||
const avgMs = spanMs / journal.flowCount;
|
||||
if (avgMs < RAPID_ITERATION_THRESHOLD_MS) {
|
||||
anomalies.push({
|
||||
type: "journal-rapid-iterations",
|
||||
severity: "warning",
|
||||
summary: `${journal.flowCount} iterations in ${formatDuration(spanMs)} (avg ${formatDuration(avgMs)}/iteration)`,
|
||||
details: `Unusually rapid iteration cadence suggests the loop may be thrashing without making progress. Review recent journal events for dispatch-stop or terminal events.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect worktree failures from journal events
|
||||
const wtCreateFailed = journal.eventCounts["worktree-create-failed"] ?? 0;
|
||||
const wtMergeFailed = journal.eventCounts["worktree-merge-failed"] ?? 0;
|
||||
const wtFailures = wtCreateFailed + wtMergeFailed;
|
||||
if (wtFailures > 0) {
|
||||
const parts: string[] = [];
|
||||
if (wtCreateFailed > 0) parts.push(`${wtCreateFailed} create failure(s)`);
|
||||
if (wtMergeFailed > 0) parts.push(`${wtMergeFailed} merge failure(s)`);
|
||||
anomalies.push({
|
||||
type: "journal-worktree-failure",
|
||||
severity: "warning",
|
||||
summary: `Worktree failures: ${parts.join(", ")}`,
|
||||
details: `Journal recorded worktree operation failures. These may indicate git state corruption or conflicting branches.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Report Persistence ───────────────────────────────────────────────────────
|
||||
|
||||
function saveForensicReport(basePath: string, report: ForensicReport, problemDescription: string): string {
|
||||
|
|
@ -600,6 +853,45 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes
|
|||
sections.push(redact(formatCrashInfo(report.crashLock)), ``);
|
||||
}
|
||||
|
||||
// Activity log metadata
|
||||
if (report.activityLogMeta) {
|
||||
const meta = report.activityLogMeta;
|
||||
sections.push(`## Activity Log Metadata`, ``);
|
||||
sections.push(`- Files: ${meta.fileCount}`);
|
||||
sections.push(`- Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`);
|
||||
if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`);
|
||||
if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`);
|
||||
sections.push(``);
|
||||
}
|
||||
|
||||
// Journal summary
|
||||
if (report.journalSummary) {
|
||||
const js = report.journalSummary;
|
||||
sections.push(`## Journal Summary`, ``);
|
||||
sections.push(`- Total entries: ${js.totalEntries}`);
|
||||
sections.push(`- Distinct flows (iterations): ${js.flowCount}`);
|
||||
sections.push(`- Daily files: ${js.fileCount}`);
|
||||
if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry} — ${js.newestEntry}`);
|
||||
sections.push(``);
|
||||
sections.push(`### Event Type Distribution`, ``);
|
||||
sections.push(`| Event Type | Count |`);
|
||||
sections.push(`|------------|-------|`);
|
||||
for (const [evType, count] of Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1])) {
|
||||
sections.push(`| ${evType} | ${count} |`);
|
||||
}
|
||||
sections.push(``);
|
||||
if (js.recentEvents.length > 0) {
|
||||
sections.push(`### Recent Journal Events (last ${js.recentEvents.length})`, ``);
|
||||
for (const ev of js.recentEvents) {
|
||||
const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`];
|
||||
if (ev.rule) parts.push(`rule=${ev.rule}`);
|
||||
if (ev.unitId) parts.push(`unit=${ev.unitId}`);
|
||||
sections.push(`- ${parts.join(" ")}`);
|
||||
}
|
||||
sections.push(``);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, sections.join("\n"), "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
|
@ -681,6 +973,41 @@ function formatReportForPrompt(report: ForensicReport): string {
|
|||
sections.push("");
|
||||
}
|
||||
|
||||
// Activity log metadata
|
||||
if (report.activityLogMeta) {
|
||||
const meta = report.activityLogMeta;
|
||||
sections.push("### Activity Log Overview");
|
||||
sections.push(`- Files: ${meta.fileCount}, Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`);
|
||||
if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`);
|
||||
if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`);
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
// Journal summary — structured event timeline
|
||||
if (report.journalSummary) {
|
||||
const js = report.journalSummary;
|
||||
sections.push("### Journal Summary (Iteration Event Log)");
|
||||
sections.push(`- Total entries: ${js.totalEntries}, Distinct flows: ${js.flowCount}, Daily files: ${js.fileCount}`);
|
||||
if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry} — ${js.newestEntry}`);
|
||||
|
||||
// Event type distribution (compact)
|
||||
const eventPairs = Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1]);
|
||||
sections.push(`- Events: ${eventPairs.map(([t, c]) => `${t}(${c})`).join(", ")}`);
|
||||
|
||||
// Recent events timeline (for tracing what just happened)
|
||||
if (js.recentEvents.length > 0) {
|
||||
sections.push("");
|
||||
sections.push(`**Recent Journal Events (last ${js.recentEvents.length}):**`);
|
||||
for (const ev of js.recentEvents) {
|
||||
const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`];
|
||||
if (ev.rule) parts.push(`rule=${ev.rule}`);
|
||||
if (ev.unitId) parts.push(`unit=${ev.unitId}`);
|
||||
sections.push(`- ${parts.join(" ")}`);
|
||||
}
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
// Completed keys count
|
||||
sections.push(`### Completed Keys: ${report.completedKeys.length}`);
|
||||
sections.push(`### GSD Version: ${report.gsdVersion}`);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ GSD extension source code is at: `{{gsdSourceDir}}`
|
|||
├── doctor-history.jsonl — doctor check history
|
||||
├── activity/ — session activity logs (JSONL per unit)
|
||||
│ └── {seq}-{unitType}-{unitId}.jsonl
|
||||
├── journal/ — structured event journal (JSONL per day)
|
||||
│ └── YYYY-MM-DD.jsonl
|
||||
├── runtime/
|
||||
│ ├── paused-session.json — serialized session when auto pauses
|
||||
│ └── headless-context.md — headless resume context
|
||||
|
|
@ -60,6 +62,32 @@ GSD extension source code is at: `{{gsdSourceDir}}`
|
|||
- `usage` field on assistant messages: `input`, `output`, `cacheRead`, `cacheWrite`, `totalTokens`, `cost`
|
||||
- **To trace a failure**: find the last activity log, search for `isError: true` tool results, then read the agent's reasoning text preceding that error
|
||||
|
||||
### Journal Format (`.gsd/journal/`)
|
||||
|
||||
The journal is a structured event log for auto-mode iterations. Each daily file contains JSONL entries:
|
||||
|
||||
```
|
||||
{ ts: "ISO-8601", flowId: "UUID", seq: 0, eventType: "iteration-start", rule?: "rule-name", causedBy?: { flowId, seq }, data?: { unitId, status, ... } }
|
||||
```
|
||||
|
||||
**Key event types:**
|
||||
- `iteration-start` / `iteration-end` — marks loop iteration boundaries
|
||||
- `dispatch-match` / `dispatch-stop` — what the auto-mode decided to do (or not do)
|
||||
- `unit-start` / `unit-end` — lifecycle of individual work units
|
||||
- `terminal` — auto-mode reached a terminal state (all done, budget exceeded, etc.)
|
||||
- `guard-block` — dispatch was blocked by a guard condition (e.g. needs user input)
|
||||
- `stuck-detected` — the loop detected it was stuck (same unit repeatedly dispatched)
|
||||
- `milestone-transition` — a milestone was promoted or completed
|
||||
- `worktree-enter` / `worktree-create-failed` / `worktree-merge-start` / `worktree-merge-failed` — worktree operations
|
||||
|
||||
**Key concepts:**
|
||||
- **flowId**: UUID grouping all events in one iteration. Use to reconstruct what happened in a single loop pass.
|
||||
- **causedBy**: Cross-reference to a prior event (same or different flow). Enables causal chain tracing.
|
||||
- **seq**: Monotonically increasing within a flow. Reconstruct event order within an iteration.
|
||||
|
||||
**To trace a stuck loop**: filter for `stuck-detected` events, then follow `flowId` to see the surrounding dispatch and unit events.
|
||||
**To trace a guard block**: filter for `guard-block` events, check `data.reason` for why dispatch was blocked.
|
||||
|
||||
### Crash Lock Format (`auto.lock`)
|
||||
|
||||
JSON with fields: `pid`, `startedAt`, `unitType`, `unitId`, `unitStartedAt`, `completedUnits`, `sessionFile`
|
||||
|
|
@ -78,20 +106,24 @@ A unit dispatched more than once (`type/id` appears multiple times) indicates a
|
|||
|
||||
1. **Start with the pre-parsed forensic report** above. The anomaly section contains automated findings — treat these as leads, not conclusions.
|
||||
|
||||
2. **Form hypotheses** about which module and code path is responsible. Use the source map to identify candidate files.
|
||||
2. **Check the journal timeline** if present. The journal events show the auto-mode's decision sequence (dispatches, guards, stuck detection, worktree operations). Use flow IDs to group related events and trace causal chains.
|
||||
|
||||
3. **Read the actual GSD source code** at `{{gsdSourceDir}}` to confirm or deny each hypothesis. Do not guess what code does — read it.
|
||||
3. **Cross-reference activity logs and journal**. Activity logs show *what the LLM did* (tool calls, reasoning, errors). Journal events show *what auto-mode decided* (dispatch rules, iteration boundaries, state transitions). Together they reveal the full picture.
|
||||
|
||||
4. **Trace the code path** from the entry point (usually `auto-loop.ts` dispatch or `auto-dispatch.ts`) through to the failure point. Follow function calls across files.
|
||||
4. **Form hypotheses** about which module and code path is responsible. Use the source map to identify candidate files.
|
||||
|
||||
5. **Identify the specific file and line** where the bug lives. Determine what kind of defect it is:
|
||||
5. **Read the actual GSD source code** at `{{gsdSourceDir}}` to confirm or deny each hypothesis. Do not guess what code does — read it.
|
||||
|
||||
6. **Trace the code path** from the entry point (usually `auto-loop.ts` dispatch or `auto-dispatch.ts`) through to the failure point. Follow function calls across files.
|
||||
|
||||
7. **Identify the specific file and line** where the bug lives. Determine what kind of defect it is:
|
||||
- Missing edge case / unhandled condition
|
||||
- Wrong boolean logic or comparison
|
||||
- Race condition or ordering issue
|
||||
- State corruption (e.g. completed-units.json out of sync with artifacts)
|
||||
- Timeout / recovery logic not triggering correctly
|
||||
|
||||
6. **Clarify if needed.** Use ask_user_questions (max 2 questions) only if the report is genuinely insufficient. Do not ask questions you can answer from the data or source code.
|
||||
8. **Clarify if needed.** Use ask_user_questions (max 2 questions) only if the report is genuinely insufficient. Do not ask questions you can answer from the data or source code.
|
||||
|
||||
## Output
|
||||
|
||||
|
|
|
|||
162
src/resources/extensions/gsd/tests/forensics-journal.test.ts
Normal file
162
src/resources/extensions/gsd/tests/forensics-journal.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const gsdDir = join(__dirname, "..");
|
||||
|
||||
describe("forensics journal & activity log awareness", () => {
|
||||
const forensicsSrc = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
|
||||
const promptSrc = readFileSync(join(gsdDir, "prompts", "forensics.md"), "utf-8");
|
||||
|
||||
it("scanJournalForForensics reads journal files directly (no full queryJournal load)", () => {
|
||||
// Must NOT use queryJournal which loads ALL entries into memory
|
||||
assert.ok(
|
||||
!forensicsSrc.includes('queryJournal('),
|
||||
"forensics.ts must NOT call queryJournal() which loads all entries at once",
|
||||
);
|
||||
// Must have its own journal scanning with file-level limits
|
||||
assert.ok(
|
||||
forensicsSrc.includes("scanJournalForForensics"),
|
||||
"forensics.ts must have scanJournalForForensics function",
|
||||
);
|
||||
});
|
||||
|
||||
it("journal scanning limits files parsed to avoid memory bloat", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("MAX_JOURNAL_RECENT_FILES"),
|
||||
"must have MAX_JOURNAL_RECENT_FILES constant to limit parsed files",
|
||||
);
|
||||
assert.ok(
|
||||
forensicsSrc.includes("MAX_JOURNAL_RECENT_EVENTS"),
|
||||
"must have MAX_JOURNAL_RECENT_EVENTS constant to limit events extracted",
|
||||
);
|
||||
});
|
||||
|
||||
it("older journal files are line-counted without full JSON parse", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("olderEntryCount") || forensicsSrc.includes("olderFiles"),
|
||||
"must handle older files separately from recent files",
|
||||
);
|
||||
});
|
||||
|
||||
it("ForensicReport includes journalSummary field", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("journalSummary"),
|
||||
"ForensicReport must include journalSummary field",
|
||||
);
|
||||
});
|
||||
|
||||
it("ForensicReport includes activityLogMeta field", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("activityLogMeta"),
|
||||
"ForensicReport must include activityLogMeta field",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildForensicReport calls scanJournalForForensics", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("scanJournalForForensics"),
|
||||
"buildForensicReport must call scanJournalForForensics",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildForensicReport calls gatherActivityLogMeta", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("gatherActivityLogMeta"),
|
||||
"buildForensicReport must call gatherActivityLogMeta",
|
||||
);
|
||||
});
|
||||
|
||||
it("forensics detects journal-based anomalies", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("detectJournalAnomalies"),
|
||||
"forensics.ts must have detectJournalAnomalies function",
|
||||
);
|
||||
// Check for specific journal anomaly types
|
||||
assert.ok(forensicsSrc.includes('"journal-stuck"'), "must detect journal-stuck anomalies");
|
||||
assert.ok(forensicsSrc.includes('"journal-guard-block"'), "must detect journal-guard-block anomalies");
|
||||
assert.ok(forensicsSrc.includes('"journal-rapid-iterations"'), "must detect journal-rapid-iterations anomalies");
|
||||
assert.ok(forensicsSrc.includes('"journal-worktree-failure"'), "must detect journal-worktree-failure anomalies");
|
||||
});
|
||||
|
||||
it("formatReportForPrompt includes journal summary section", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("Journal Summary"),
|
||||
"prompt formatter must include a Journal Summary section",
|
||||
);
|
||||
});
|
||||
|
||||
it("formatReportForPrompt includes activity log overview section", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("Activity Log Overview"),
|
||||
"prompt formatter must include an Activity Log Overview section",
|
||||
);
|
||||
});
|
||||
|
||||
it("activity log scanning uses tail-read with byte cap (not full file load)", () => {
|
||||
// scanActivityLogs uses nativeParseJsonlTail + MAX_JSONL_BYTES for efficient reading
|
||||
assert.ok(
|
||||
forensicsSrc.includes("nativeParseJsonlTail"),
|
||||
"activity log scanning must use nativeParseJsonlTail for tail-reading",
|
||||
);
|
||||
assert.ok(
|
||||
forensicsSrc.includes("MAX_JSONL_BYTES"),
|
||||
"activity log scanning must respect MAX_JSONL_BYTES cap",
|
||||
);
|
||||
// Only reads last 5 files
|
||||
assert.ok(
|
||||
forensicsSrc.includes("slice(-5)"),
|
||||
"activity log scanning must limit to last 5 files",
|
||||
);
|
||||
});
|
||||
|
||||
it("activity log entries are distilled through extractTrace, not sent raw", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("extractTrace("),
|
||||
"activity log entries must be distilled through extractTrace before reporting",
|
||||
);
|
||||
});
|
||||
|
||||
it("prompt output is hard-capped at 30KB", () => {
|
||||
assert.ok(
|
||||
forensicsSrc.includes("MAX_BYTES") && forensicsSrc.includes("30 * 1024"),
|
||||
"formatReportForPrompt must have a 30KB hard cap",
|
||||
);
|
||||
assert.ok(
|
||||
forensicsSrc.includes("truncated at 30KB"),
|
||||
"prompt must show truncation message when capped",
|
||||
);
|
||||
});
|
||||
|
||||
it("forensics prompt documents journal format", () => {
|
||||
assert.ok(
|
||||
promptSrc.includes("### Journal Format"),
|
||||
"forensics.md must document the journal format",
|
||||
);
|
||||
assert.ok(
|
||||
promptSrc.includes("flowId"),
|
||||
"forensics.md must reference flowId concept",
|
||||
);
|
||||
assert.ok(
|
||||
promptSrc.includes("causedBy"),
|
||||
"forensics.md must reference causedBy for causal chains",
|
||||
);
|
||||
});
|
||||
|
||||
it("forensics prompt includes journal directory in runtime path reference", () => {
|
||||
assert.ok(
|
||||
promptSrc.includes("journal/"),
|
||||
"forensics.md runtime path reference must include journal/",
|
||||
);
|
||||
});
|
||||
|
||||
it("investigation protocol references journal data", () => {
|
||||
assert.ok(
|
||||
promptSrc.includes("journal timeline") || promptSrc.includes("journal events"),
|
||||
"investigation protocol must reference journal data for tracing",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -69,6 +69,8 @@ describe("diagnostics type exports", () => {
|
|||
unitTraces: [],
|
||||
completedKeyCount: 0,
|
||||
metrics: null,
|
||||
journalSummary: null,
|
||||
activityLogMeta: null,
|
||||
}
|
||||
assert.equal(typeof report.gsdVersion, "string")
|
||||
assert.equal(typeof report.timestamp, "string")
|
||||
|
|
@ -79,6 +81,8 @@ describe("diagnostics type exports", () => {
|
|||
assert.equal(typeof report.doctorIssueCount, "number")
|
||||
assert.equal(typeof report.unitTraceCount, "number")
|
||||
assert.equal(typeof report.completedKeyCount, "number")
|
||||
assert.equal(report.journalSummary, null)
|
||||
assert.equal(report.activityLogMeta, null)
|
||||
})
|
||||
|
||||
it("ForensicMetricsSummary has required fields", () => {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise
|
|||
' unitTraces,',
|
||||
' completedKeyCount: (report.completedKeys || []).length,',
|
||||
' metrics,',
|
||||
' journalSummary: report.journalSummary || null,',
|
||||
' activityLogMeta: report.activityLogMeta || null,',
|
||||
'};',
|
||||
'process.stdout.write(JSON.stringify(result));',
|
||||
].join(" ")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ export type ForensicAnomalyType =
|
|||
| "crash"
|
||||
| "doctor-issue"
|
||||
| "error-trace"
|
||||
| "journal-stuck"
|
||||
| "journal-guard-block"
|
||||
| "journal-rapid-iterations"
|
||||
| "journal-worktree-failure"
|
||||
|
||||
export interface ForensicAnomaly {
|
||||
type: ForensicAnomalyType
|
||||
|
|
@ -56,6 +60,23 @@ export interface ForensicRecentUnit {
|
|||
finishedAt: number
|
||||
}
|
||||
|
||||
export interface ForensicActivityLogMeta {
|
||||
fileCount: number
|
||||
totalSizeBytes: number
|
||||
oldestFile: string | null
|
||||
newestFile: string | null
|
||||
}
|
||||
|
||||
export interface ForensicJournalSummary {
|
||||
totalEntries: number
|
||||
flowCount: number
|
||||
eventCounts: Record<string, number>
|
||||
recentEvents: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[]
|
||||
oldestEntry: string | null
|
||||
newestEntry: string | null
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export interface ForensicReport {
|
||||
gsdVersion: string
|
||||
timestamp: string
|
||||
|
|
@ -70,6 +91,8 @@ export interface ForensicReport {
|
|||
unitTraces: ForensicUnitTrace[]
|
||||
completedKeyCount: number
|
||||
metrics: ForensicMetricsSummary | null
|
||||
journalSummary: ForensicJournalSummary | null
|
||||
activityLogMeta: ForensicActivityLogMeta | null
|
||||
}
|
||||
|
||||
// ─── Doctor ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue