81 lines
2.6 KiB
TypeScript
81 lines
2.6 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|