import { existsSync, statSync } from "node:fs"; import { artifactsDirFor, readRunState } from "./artifacts"; import { isStaleRunning } from "./status"; import type { SubagentRunDetails } from "./types"; import { ResumeError, type ResumeFailureReason } from "./session"; const MAX_TIMELINE_EVENTS = 8; const MAX_ASSISTANT_EXCERPT = 500; const MAX_TASK_EXCERPT = 200; export interface InspectionReport { runId: string; artifactsDir: string; phase: string; isStale: boolean; resumable: boolean; resumeBlockReason?: ResumeFailureReason; warnings: string[]; rendered: string; } export function inspectRun(runId: string, artifactsDirOverride?: string, now = Date.now()): InspectionReport { const artifactsDir = artifactsDirOverride ?? artifactsDirFor(runId); if (!existsSync(artifactsDir)) { throw new ResumeError( "run_not_found", `No run directory at ${artifactsDir}`, "Verify the runId is correct and the state/subagents// directory exists.", { runId, artifactsDir }, ); } let persisted: SubagentRunDetails; try { persisted = readRunState(artifactsDir); } catch (err: any) { throw new ResumeError( "unknown_format", `run.json at ${artifactsDir} is corrupt or unreadable: ${err?.message ?? String(err)}`, "Inspect the file manually; consider deleting the run directory to start fresh.", { runId, artifactsDir }, ); } if (!persisted || typeof persisted !== "object" || typeof persisted.phase !== "string") { throw new ResumeError( "unknown_format", `run.json at ${artifactsDir} is corrupt or missing required fields`, "Inspect the file manually; consider deleting the run directory to start fresh.", { runId, artifactsDir }, ); } if (persisted.runId && persisted.runId !== runId) { throw new ResumeError( "runid_mismatch", `Requested runId ${runId}, persisted runId ${persisted.runId}`, "Use the runId stored in the run directory, not a different one.", { runId, persistedRunId: persisted.runId, artifactsDir }, ); } let mtimeMs = persisted.startedAt ?? now; try { mtimeMs = statSync(artifactsDir).mtimeMs; } catch { // Fallback to startedAt when stat is unavailable. } const stale = isStaleRunning(persisted, mtimeMs, now); const warnings: string[] = []; let resumable = true; let resumeBlockReason: ResumeFailureReason | undefined; if (persisted.phase === "done") { resumable = false; resumeBlockReason = "done_run_cannot_resume"; } else if (!persisted.sessionFile) { resumable = false; resumeBlockReason = "session_file_missing"; } else if (!existsSync(persisted.sessionFile)) { resumable = false; resumeBlockReason = "session_file_unreadable"; } if (stale) { warnings.push("Process appears stale (mtime > 1h); session may have crashed silently while marked running."); } if (persisted.toolErrors && persisted.toolErrors.length > 0) { warnings.push(`${persisted.toolErrors.length} tool call(s) failed during the run; inspect details.toolErrors.`); } const rendered = renderReport(persisted, { artifactsDir, mtimeMs, stale, resumable, resumeBlockReason, warnings, now, }); return { runId, artifactsDir, phase: persisted.phase, isStale: stale, resumable, resumeBlockReason, warnings, rendered }; } function renderReport( d: SubagentRunDetails, ctx: { artifactsDir: string; mtimeMs: number; stale: boolean; resumable: boolean; resumeBlockReason?: ResumeFailureReason; warnings: string[]; now: number }, ): string { const lines: string[] = []; const phaseLabel = ctx.stale ? `${d.phase} (stale running — mtime ${formatAgo(ctx.now - ctx.mtimeMs)} ago)` : d.phase; lines.push(`Run: ${d.runId ?? "(unknown)"}`); lines.push(`Phase: ${phaseLabel}`); if (d.agent?.name) { const meta = [ d.agent.name, d.agent.model ? `model=${d.agent.model}` : null, d.agent.effort ? `effort=${d.agent.effort}` : null, ].filter(Boolean); lines.push(`Agent: ${meta.join(" · ")}`); } if (d.task) { const task = clip(d.task, MAX_TASK_EXCERPT); lines.push(`Task: ${task}`); } lines.push(`Cwd: ${d.cwd ?? "(unknown)"}`); if (d.startedAt) { lines.push(`Started: ${new Date(d.startedAt).toISOString()}`); } if (d.endedAt) { lines.push(`Ended: ${new Date(d.endedAt).toISOString()}`); } lines.push(`Last activity: ${formatAgo(ctx.now - ctx.mtimeMs)} ago`); lines.push(`ArtifactsDir: ${ctx.artifactsDir}`); if (d.sessionFile) lines.push(`SessionFile: ${d.sessionFile}`); const timeline = Array.isArray(d.timeline) ? d.timeline.slice(-MAX_TIMELINE_EVENTS) : []; if (timeline.length > 0) { lines.push("", `Timeline (last ${timeline.length} events):`); for (const event of timeline) { const stamp = (event as any).at ? new Date((event as any).at).toISOString().slice(11, 19) : "??:??:??"; const kind = event.kind ?? "event"; const text = clip(String(event.text ?? ""), 120); lines.push(` ${stamp} ${kind} ${text}`); } } const lastText = d.salvagedFinalText || d.finalText || d.rawSessionOutput || ""; if (lastText) { lines.push("", "Last assistant text (excerpt):"); lines.push(clip(lastText, MAX_ASSISTANT_EXCERPT).split("\n").map((l) => ` ${l}`).join("\n")); } lines.push("", "Resume assessment:"); if (ctx.resumable) { lines.push(" ✓ Run can be resumed (phase != done, session file present)"); } else { lines.push(` ✗ Run cannot be resumed (reason: ${ctx.resumeBlockReason ?? "unknown"})`); } for (const warn of ctx.warnings) { lines.push(` ⚠ ${warn}`); } if (ctx.resumable) { lines.push(` → Suggested action: subagent({ resumeRunId: "${d.runId}" }) to continue, or start a new subagent if work needs different approach.`); } else { lines.push(" → Suggested action: start a new subagent with a fresh task."); } return lines.join("\n"); } function clip(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "…"; } function formatAgo(diffMs: number): string { if (diffMs < 0) return "just now"; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ${seconds % 60}s`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${minutes % 60}m`; const days = Math.floor(hours / 24); return `${days}d ${hours % 24}h`; } export const __testables = { renderReport, clip, formatAgo, MAX_TIMELINE_EVENTS, MAX_ASSISTANT_EXCERPT, MAX_TASK_EXCERPT, };