import * as path from "node:path"; import { buildActionableWarningsReport, formatActionableWarningsAdvisory, writeActionableWarningsReport, } from "./actionable-warnings.js"; import { logActionableWarningsEvent } from "./actionable-warnings-logger.js"; import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js"; import type { CacheManager } from "./cache-manager.js"; import { logCascade } from "./cascade-logger.js"; import { normalizeMapKey } from "./path-utils.js"; import type { DependencyChecker } from "./dependency-checker.js"; import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js"; import { getKnipIgnorePatterns } from "./file-utils.js"; import type { KnipClient, KnipIssue, KnipResult } from "./knip-client.js"; import { logLatency } from "./latency-logger.js"; import { emitLensTurnFindings } from "./lens-events.js"; import { RUNTIME_CONFIG } from "./runtime-config.js"; import type { RuntimeCoordinator } from "./runtime-coordinator.js"; import type { TestRunnerClient } from "./test-runner-client.js"; interface TurnEndDeps { ctxCwd?: string; getFlag: (name: string) => boolean | string | undefined; dbg: (msg: string) => void; runtime: RuntimeCoordinator; cacheManager: CacheManager; knipClient: KnipClient; depChecker: DependencyChecker; testRunnerClient: TestRunnerClient; resetLSPService: () => void; resetFormatService: () => void; } // LSP idle reset scheduling — prevents thrashing by delaying shutdown let lspIdleResetTimeout: ReturnType | null = null; function scheduleLSPIdleReset(resetFn: () => void, delayMs: number): void { // Clear any pending reset to avoid multiple timers if (lspIdleResetTimeout) { clearTimeout(lspIdleResetTimeout); } lspIdleResetTimeout = setTimeout(() => { resetFn(); lspIdleResetTimeout = null; }, delayMs); // unref so this timer does not prevent the process from exiting naturally // (critical for subagent / --mode json -p usage where the process should // exit after completing its work, not wait 240 seconds for this to fire) lspIdleResetTimeout.unref(); } export function cancelLSPIdleReset(): void { if (lspIdleResetTimeout) { clearTimeout(lspIdleResetTimeout); lspIdleResetTimeout = null; } } function capTurnEndMessage(content: string): string { const maxLines = RUNTIME_CONFIG.turnEnd.maxLines; const maxChars = RUNTIME_CONFIG.turnEnd.maxChars; let out = content; const lines = out.split("\n"); if (lines.length > maxLines) { out = `${lines.slice(0, maxLines).join("\n")}\n... (truncated)`; } if (out.length > maxChars) { out = `${out.slice(0, maxChars)}\n... (truncated)`; } return out; } export async function handleTurnEnd(deps: TurnEndDeps): Promise { const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, depChecker, testRunnerClient, resetLSPService, resetFormatService, } = deps; const cwd = ctxCwd ?? process.cwd(); let turnState = cacheManager.readTurnState(cwd); // Evict turn state written by a previous session — it carries stale file // ranges that no longer reflect the current editing context. if ( turnState.sessionId && turnState.sessionId !== runtime.telemetrySessionId ) { dbg( `turn_end: evicting stale turn state (session ${turnState.sessionId} ≠ current ${runtime.telemetrySessionId})`, ); cacheManager.clearTurnState(cwd); turnState = cacheManager.readTurnState(cwd); } const files = Object.keys(turnState.files); if (files.length === 0) { dbg("turn_end: no modified files, scheduling LSP idle reset (240s)"); if (!getFlag("no-lsp")) { scheduleLSPIdleReset(resetLSPService, 240_000); } resetFormatService(); return; } // Cancel any pending idle reset since we're actively working if (lspIdleResetTimeout) { clearTimeout(lspIdleResetTimeout); lspIdleResetTimeout = null; dbg("turn_end: cancelled pending LSP idle reset (active editing)"); } dbg( `turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`, ); if (cacheManager.isMaxCyclesExceeded(cwd)) { dbg("turn_end: max cycles exceeded, clearing state and forcing through"); cacheManager.clearTurnState(cwd); runtime.fixedThisTurn.clear(); resetFormatService(); return; } const turnEndStart = Date.now(); const blockerParts: string[] = []; const advisoryParts: string[] = []; // Re-surface inline blockers from this turn that the agent didn't fix. // These were shown inline during write/edit but the agent moved on without resolving them. const unresolvedBlockers = runtime.consumeInlineBlockers(); for (const { filePath: bPath, summary } of unresolvedBlockers) { const displayPath = toRunnerDisplayPath(cwd, bPath); blockerParts.push( `Unresolved from this turn — ${displayPath}:\n${summary}`, ); } // Merge accumulated cascade results from all pipeline runs this turn. // Two-pass dedup: // 1. Primary-level: dedup by primary file (last writer wins). // 2. Neighbor-level: each neighbor is claimed by the latest cascade result // that covers it — suppresses stale neighbor state from earlier writes. const t0 = Date.now(); const cascadeResults = runtime.consumeCascadeResults(); if (cascadeResults.length > 0) { const seen = new Map(); for (const result of cascadeResults) { seen.set(normalizeMapKey(result.filePath), result); } // Iterate in reverse so the latest result claims each neighbor first. const neighborOwner = new Map(); for (const result of [...seen.values()].reverse()) { const pk = normalizeMapKey(result.filePath); for (const n of result.neighbors) { const nk = normalizeMapKey(n.filePath); if (!neighborOwner.has(nk)) neighborOwner.set(nk, pk); } } const parts: string[] = []; for (const result of seen.values()) { const pk = normalizeMapKey(result.filePath); const ownsAny = result.neighbors.some( (n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk, ); if (ownsAny && result.formatted) parts.push(result.formatted); } // Suggest tests for cascade neighbors (files with diagnostics) const neighborFilesWithErrors = cascadeResults .flatMap((r) => r.neighbors) .filter((n) => n.diagnostics.length > 0) .map((n) => n.filePath); const uniqueNeighborFiles = [...new Set(neighborFilesWithErrors)]; if ( uniqueNeighborFiles.length > 0 && typeof testRunnerClient.suggestTestFiles === "function" ) { const testSuggestions = testRunnerClient.suggestTestFiles( uniqueNeighborFiles, cwd, ); if (testSuggestions.length > 0) { const testLines = testSuggestions .slice(0, 5) .map( (s) => ` ${toRunnerDisplayPath(cwd, s.testFile)} (${s.runner})`, ); let testSection = `🧪 Likely tests for affected neighbors:\n${testLines.join("\n")}`; if (testSuggestions.length > 5) { testSection += `\n ... and ${testSuggestions.length - 5} more`; } parts.push(testSection); } } if (parts.length > 0) blockerParts.push(parts.join("\n\n")); logCascade({ phase: "cascade_turn_end", filePath: files[0] ?? cwd, neighborCount: cascadeResults.reduce((s, r) => s + r.neighbors.length, 0), diagnosticCount: cascadeResults.reduce( (s, r) => s + r.neighbors.reduce((ns, n) => ns + n.diagnostics.length, 0), 0, ), metadata: { fileCount: cascadeResults.length, mergedResults: seen.size, }, }); } logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "cascade_merge", durationMs: Date.now() - t0, metadata: { resultCount: cascadeResults.length }, }); const t2 = Date.now(); let knipMeta: { skipped?: boolean; success?: boolean; totalIssues?: number; newIssues?: number; blockerIssues?: number; reason?: string; } = {}; if (runtime.isStartupScanInFlight("knip")) { dbg("turn_end: skipping knip (startup scan still in flight)"); knipMeta = { skipped: true }; } else { // Let KnipClient resolve/validate a real JS project root before probing or // auto-installing knip. Non-JS repos (for example Unity projects) should not // run tool checks every turn. Also back off after a timeout/kill so every // agent turn does not spend 30s launching another heavyweight knip process. const prevKnip = cacheManager.readCache("knip", cwd); const previousFailedHard = prevKnip && !prevKnip.data.success && /(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prevKnip.data.summary); if (previousFailedHard) { dbg( `turn_end: skipping knip after recent failure: ${prevKnip.data.summary}`, ); knipMeta = { skipped: true, reason: prevKnip.data.summary }; } else { const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns()); cacheManager.writeCache("knip", knipResult, cwd); knipMeta = { success: knipResult.success, totalIssues: knipResult.issues.length, newIssues: 0, blockerIssues: 0, ...(!knipResult.success && { reason: knipResult.summary }), }; if (knipResult.success && knipResult.issues.length > 0) { const issueKey = (i: KnipIssue) => `${i.type}:${i.file ?? ""}:${i.name}:${i.line ?? 0}:${i.package ?? ""}`; const prevKeys = new Set((prevKnip?.data?.issues ?? []).map(issueKey)); const modifiedSet = new Set( files.map((f) => resolveRunnerPath(cwd, f)), ); const newIssues = knipResult.issues.filter((issue) => { if (prevKeys.has(issueKey(issue))) return false; if (!issue.file) return false; const abs = resolveRunnerPath(cwd, issue.file); return modifiedSet.has(abs); }); knipMeta.newIssues = newIssues.length; const blockerIssues = newIssues.filter( (i) => i.type === "unlisted" || i.type === "bin", ); knipMeta.blockerIssues = blockerIssues.length; if (blockerIssues.length > 0) { let report = "🔴 New unresolved imports/deps in modified code (Knip):\n"; let firstPath: string | null = null; for (const issue of blockerIssues.slice(0, 5)) { const display = issue.file ? toRunnerDisplayPath(cwd, issue.file) : "(unknown)"; if (!firstPath && display !== "(unknown)") firstPath = display; report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.type}: ${issue.name}\n`; } if (firstPath) { report += ` First location: ${firstPath}\n`; } blockerParts.push(report); } // Newly-unused exports in modified files: symbol was clean before this turn // (not in prevKnip issues) but is now flagged — likely a caller was removed or // an interface changed. Advisory only — the agent may be mid-task. const unusedExportIssues = newIssues.filter((i) => i.type === "export"); if (unusedExportIssues.length > 0) { let report = "⚠️ Newly unused exports in modified files — check if callers need updating (Knip):\n"; for (const issue of unusedExportIssues.slice(0, 5)) { const display = issue.file ? toRunnerDisplayPath(cwd, issue.file) : "(unknown)"; report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.name}\n`; } advisoryParts.push(report); } } } } logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "knip", durationMs: Date.now() - t2, metadata: knipMeta, }); const t3 = Date.now(); if (await depChecker.ensureAvailable()) { const madgeFiles = cacheManager.getFilesForMadge(cwd); if (madgeFiles.length > 0) { dbg( `turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`, ); for (const file of madgeFiles) { const absPath = path.resolve(cwd, file); const depResult = await depChecker.checkFile(absPath, cwd); if (depResult.hasCircular && depResult.circular.length > 0) { const circularDeps = depResult.circular .flatMap((d) => d.path) .filter((p: string) => !absPath.endsWith(path.basename(p))); const uniqueDeps = [...new Set(circularDeps)]; if (uniqueDeps.length > 0) { dbg( `turn_end: circular dependency note for ${file} (suppressed in blockers-only mode)`, ); } } } } } logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "madge", durationMs: Date.now() - t3, }); // --- Test runner: fire once per turn after all edits are done --- // Runs for each unique test target across modified files; results appear // in the next turn's context injection alongside jscpd/madge findings. if (!getFlag("no-tests") && files.length > 0) { const seen = new Set(); const targets: NonNullable< ReturnType >[] = []; for (const file of files) { const abs = resolveRunnerPath(cwd, file); const target = testRunnerClient.getTestRunTarget(abs, cwd); if (target && !seen.has(target.testFile)) { seen.add(target.testFile); targets.push(target); dbg( `turn_end: ${file} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy})`, ); } else if (!target) { dbg(`turn_end: ${file} → no test file found`); } } if (targets.length > 0) { dbg( `turn_end: firing ${targets.length} test target(s) async (non-blocking)`, ); const firedAtTurn = runtime.turnIndex; Promise.allSettled( targets.map((t) => testRunnerClient.runTestFileAsync( t.testFile, cwd, t.runner, t.config, ), ), ) .then((results) => { const stale = runtime.turnIndex !== firedAtTurn; const failures: string[] = []; for (const r of results) { if (r.status === "rejected") { dbg(`turn_end: test run rejected — ${r.reason}`); continue; } const { file, runner, passed, failed, duration, error } = r.value; const shortFile = path.basename(file); const summary = error && passed === 0 && failed === 0 ? `error: ${error}` : `${failed > 0 ? "FAIL" : "PASS"} ${passed}p/${failed}f (${duration}ms)`; dbg( `turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`, ); if (!stale && failed > 0) { const formatted = testRunnerClient.formatResult(r.value); if (formatted) failures.push(formatted); } } if (stale) { dbg( `turn_end: discarding test results — turn advanced while tests ran`, ); return; } if (failures.length > 0) { const content = failures.join("\n\n"); cacheManager.writeCache("test-runner-findings", { content }, cwd); dbg( `turn_end: ${failures.length} test failure(s) cached for next context injection`, ); } else if (results.length > 0) { dbg(`turn_end: all tests passed`); } }) .catch(() => {}); } } if (runtime.errorDebtBaseline && files.length > 0) { dbg("turn_end: marking error debt check for next session"); cacheManager.writeCache( "errorDebt", { pendingCheck: true, baselineTestsPassed: runtime.errorDebtBaseline.testsPassed, }, cwd, ); } // Session summaries are intentionally suppressed at turn_end to avoid // distracting the agent with non-blocking telemetry. const t4 = Date.now(); const modifiedRangesByFile = new Map( Object.entries(turnState.files).map(([file, state]) => [ normalizeMapKey(resolveRunnerPath(cwd, file)), state.modifiedRanges, ]), ); const getFileSeq = (runtime as Partial).getFileSeq; const fileSeqByPath = new Map(); if (getFileSeq) { for (const file of files) { const filePath = normalizeMapKey(resolveRunnerPath(cwd, file)); fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath)); } } if (getFlag("lens-actionable-warnings")) { try { const report = await buildActionableWarningsReport({ cwd, sessionId: runtime.telemetrySessionId, turnIndex: runtime.turnIndex, files, modifiedRangesByFile, dispatchWarnings: runtime.peekActionableWarnings(), includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"), projectSeqStart: runtime.turnStartProjectSeq, projectSeqEnd: runtime.projectSeq, fileSeqByPath, deltaOnly: !getFlag("lens-actionable-warning-all"), dbg, }); writeActionableWarningsReport(cacheManager, cwd, report); const advisory = formatActionableWarningsAdvisory(report); if (advisory) advisoryParts.push(advisory); logActionableWarningsEvent({ event: advisory ? "advisory_injected" : "advisory_skipped", sessionId: runtime.telemetrySessionId, metadata: { turnIndex: runtime.turnIndex, unsuppressed: report.summary.unsuppressed, }, }); logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "actionable_warnings_report", durationMs: Date.now() - t4, metadata: report.summary, }); } catch (err) { dbg(`turn_end: actionable warning report failed: ${err}`); logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "actionable_warnings_report", durationMs: Date.now() - t4, metadata: { failed: true, error: err instanceof Error ? err.message : String(err), }, }); } } const t5 = Date.now(); try { const qualityReport = buildCodeQualityWarningsReport({ cwd, sessionId: runtime.telemetrySessionId, turnIndex: runtime.turnIndex, warnings: runtime.peekCodeQualityWarnings(), modifiedRangesByFile, projectSeqStart: runtime.turnStartProjectSeq, projectSeqEnd: runtime.projectSeq, fileSeqByPath, }); writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport); appendCodeQualityWarningsHistory(cwd, qualityReport); const advisory = formatCodeQualityWarningsAdvisory(qualityReport); if (advisory) advisoryParts.push(advisory); logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "code_quality_warnings_report", durationMs: Date.now() - t5, metadata: qualityReport.summary, }); } catch (err) { dbg(`turn_end: code quality warning report failed: ${err}`); logLatency({ type: "phase", toolName: "turn_end", filePath: cwd, phase: "code_quality_warnings_report", durationMs: Date.now() - t5, metadata: { failed: true, error: err instanceof Error ? err.message : String(err), }, }); } cacheManager.incrementTurnCycle(cwd); const labeledAdvisoryParts = advisoryParts.map( (p) => `ℹ️ Advisory — no action required this turn:\n${p}`, ); const findingParts = [...blockerParts, ...labeledAdvisoryParts]; if (findingParts.length > 0) { dbg( `turn_end: ${blockerParts.length} blocker section(s), ${advisoryParts.length} advisory section(s) found, persisting for next context`, ); const content = capTurnEndMessage(findingParts.join("\n\n")); const signature = `${files .slice() .sort((a, b) => a.localeCompare(b)) .join("|")}::${content}`; const last = cacheManager.readCache<{ signature: string; sessionId: string; }>("turn-end-findings-last", cwd); if ( last?.data?.signature === signature && last?.data?.sessionId === runtime.telemetrySessionId ) { dbg( "turn_end: duplicate findings detected (same session), suppressing re-prompt", ); cacheManager.clearTurnState(cwd); runtime.fixedThisTurn.clear(); resetFormatService(); return; } cacheManager.writeCache("turn-end-findings", { content }, cwd); cacheManager.writeCache( "turn-end-findings-last", { signature, sessionId: runtime.telemetrySessionId, projectSeqStart: runtime.turnStartProjectSeq, projectSeqEnd: runtime.projectSeq, }, cwd, ); emitLensTurnFindings({ cwd, filePaths: files.map((file) => resolveRunnerPath(cwd, file)), sessionId: runtime.telemetrySessionId, turnIndex: runtime.turnIndex, blockerSections: blockerParts.length, advisorySections: advisoryParts.length, content, }); } if (blockerParts.length === 0) { cacheManager.clearTurnState(cwd); } runtime.fixedThisTurn.clear(); runtime.clearActionableWarnings(); runtime.clearCodeQualityWarnings(); logLatency({ type: "tool_result", toolName: "turn_end", filePath: cwd, durationMs: Date.now() - turnEndStart, result: blockerParts.length > 0 ? "blockers_found" : "clean", metadata: { fileCount: files.length, blockerSections: blockerParts.length, advisorySections: advisoryParts.length, }, }); resetFormatService(); }