/** * KHAELOR * File: src/verify/runner.ts * Description: Native verification runner — parallel checks, errors-first truncation, bounded repair loop accounting (v2 design §4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { DurableEvent, DurableEventInput } from "../session/index.js"; import type { Workspace } from "../workspace/index.js"; import type { VerifyCheck, VerifyConfig } from "./config.js"; /** The session seam the runner needs — satisfied by EventLogSession. */ export interface VerifySessionHandle { events(): readonly DurableEvent[]; publishDurable(input: DurableEventInput): unknown; } export interface VerifyOutcome { ok: boolean; results: { check: string; ok: boolean; exitCode: number | null; durationMs: number }[]; } const OUTPUT_BUDGET = 4000; const ERROR_LINE = /error|fail|✗|FAIL|Err\b|exception/i; /** * Errors-first truncation: lines that look like failures are kept ahead of * everything else, then head-fill up to the budget (v2 §4 — "tronqué * intelligemment: erreurs d'abord"). */ export function truncateErrorsFirst(output: string, budget = OUTPUT_BUDGET): string { if (output.length <= budget) return output; const lines = output.split("\n"); const errorLines: string[] = []; const otherLines: string[] = []; for (const line of lines) { if (ERROR_LINE.test(line)) errorLines.push(line); else otherLines.push(line); } const kept: string[] = []; let used = 0; for (const line of [...errorLines, ...otherLines]) { if (used + line.length + 1 > budget) break; kept.push(line); used += line.length + 1; } return `${kept.join("\n")}\n[... verify output truncated: errors shown first]`; } /** Count failing verify results since the last user message — the repair-loop bound. */ export function countRepairFailures(events: readonly DurableEvent[]): number { let count = 0; for (const event of events) { if (event.type === "user.message-created") count = 0; else if (event.type === "verify.result" && !event.payload.ok) count += 1; } return count; } export interface VerifyRunnerOptions { workspace: Workspace; session: VerifySessionHandle; config: VerifyConfig; } /** * Runs the configured checks through the workspace seam, publishes one * durable `verify.result` per check, and enforces the bounded repair loop: * once `maxRepairLoops` failing rounds have been recorded since the last * user message, the runner stops re-running — the agent reports honestly * instead of looping (v2 §4). */ export class VerifyRunner { readonly #workspace: Workspace; readonly #session: VerifySessionHandle; readonly #config: VerifyConfig; constructor(options: VerifyRunnerOptions) { this.#workspace = options.workspace; this.#session = options.session; this.#config = options.config; } get policy(): VerifyConfig["policy"] { return this.#config.policy; } get hasChecks(): boolean { return this.#config.checks.length > 0; } /** True when another failing round is still within the repair budget. */ withinRepairBudget(): boolean { return countRepairFailures(this.#session.events()) < this.#config.maxRepairLoops; } /** Run all checks (autofix checks last, serially) and record the results. */ async runAll(signal?: AbortSignal): Promise { const parallel = this.#config.checks.filter((check) => !check.autofix); const fixers = this.#config.checks.filter((check) => check.autofix); const results: VerifyOutcome["results"] = []; const settled = await Promise.all(parallel.map((check) => this.#runOne(check, signal))); results.push(...settled); for (const fixer of fixers) { if (signal?.aborted === true) break; results.push(await this.#runOne(fixer, signal)); } return { ok: results.every((result) => result.ok), results }; } async #runOne( check: VerifyCheck, signal?: AbortSignal, ): Promise { const startedAt = Date.now(); let exitCode: number | null = null; let output = ""; try { const result = await this.#workspace.exec({ cmd: check.cmd, timeoutMs: check.timeoutMs, ...(signal !== undefined ? { signal } : {}), }); exitCode = result.exitCode; output = [result.stdout, result.stderr].filter((part) => part.length > 0).join("\n"); } catch (error) { exitCode = null; output = error instanceof Error ? error.message : String(error); } const ok = exitCode === 0; const durationMs = Date.now() - startedAt; this.#session.publishDurable({ type: "verify.result", payload: { check: check.name, command: check.cmd, ok, exitCode, output: ok ? "" : truncateErrorsFirst(output), durationMs, }, }); return { check: check.name, ok, exitCode, durationMs }; } }