/** * Convergence detector for the workflow loop. * * Detects two stop conditions: * 1. The same finding (same path + normalized summary) persists for 2+ consecutive cycles. * 2. Findings oscillate between cycles (a finding disappears then reappears). * * Both conditions produce needs_human. */ import type { FindingDecision } from "./workflow-types.js"; export interface FindingSignature { path?: string; normalizedSummary: string; } /** Normalize a finding summary for stable comparison. */ function normalizeSummary(summary: string): string { return summary .toLowerCase() .replace(/[^\w\s]/g, " ") .replace(/\s+/g, " ") .trim(); } /** Build convergence signatures from the issues Codex accepted for fixing. */ export function decisionSignatures(findings: FindingDecision[]): FindingSignature[] { return findings .filter((finding) => finding.disposition === "accept") .map((finding) => ({ ...(finding.path ? { path: finding.path } : {}), normalizedSummary: normalizeSummary(finding.summary || finding.rationale || finding.findingId), })); } function sigKey(sig: FindingSignature): string { return `${sig.path ?? ""}::${sig.normalizedSummary}`; } export interface ConvergenceCheckResult { converging: boolean; /** Set when the same finding persists for 2 consecutive cycles. */ persistentFindings?: string[]; /** Set when findings oscillate (disappeared then reappeared). */ oscillatingFindings?: string[]; } /** * Check the last few rounds' signatures for convergence. * * @param history Array of per-cycle finding signatures, oldest first. */ export function checkConvergence(history: FindingSignature[][]): ConvergenceCheckResult { if (history.length < 2) return { converging: true }; const last = history[history.length - 1]!; const prev = history[history.length - 2]!; const lastKeys = new Set(last.map(sigKey)); const prevKeys = new Set(prev.map(sigKey)); // Persistent: same key in both consecutive cycles const persistent = [...lastKeys].filter((k) => prevKeys.has(k)); if (persistent.length > 0) { return { converging: false, persistentFindings: persistent }; } // Oscillating: check 3+ cycles (a key was present, absent, then present again) if (history.length >= 3) { const earlier = history[history.length - 3]!; const earlierKeys = new Set(earlier.map(sigKey)); const oscillating = [...lastKeys].filter((k) => earlierKeys.has(k) && !prevKeys.has(k)); if (oscillating.length > 0) { return { converging: false, oscillatingFindings: oscillating }; } } return { converging: true }; }