diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 4a5935784f..fd53f064b2 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -228,6 +228,14 @@ export { type IterationState, type SelfReviewOutcome, } from "./miner/iterate-policy.js"; +export { + runIterateLoop, + type IterateLoopDeps, + type IterateLoopInput, + type IterateLoopIterationRecord, + type IterateLoopOutcome, + type IterateLoopResult, +} from "./miner/iterate-loop.js"; export { codingAgentModeExecutes, isGlobalMinerCodingAgentPause, diff --git a/packages/gittensory-engine/src/miner/iterate-loop.ts b/packages/gittensory-engine/src/miner/iterate-loop.ts new file mode 100644 index 0000000000..ef54da30cc --- /dev/null +++ b/packages/gittensory-engine/src/miner/iterate-loop.ts @@ -0,0 +1,310 @@ +// Local create->score->self-review->decide iterate-loop orchestrator (#2333): the actual autonomous control +// flow Phase 3 exists to build. Repeatedly invokes a `CodingAgentDriver` (coding-agent-driver.ts), self-reviews +// the resulting diff against the byte-identical predicted-gate target (self-review-adapter.ts, #2334), and +// consults the pure policy (iterate-policy.ts, #2335) to decide -- autonomously, no human in the loop at this +// stage -- whether to keep iterating, hand off to Phase 4 submission, or abandon. +// +// TAGGED maintainer (not contributor) per the phase brief: this orchestration control flow is the precise +// chokepoint the fixed architecture skeleton's safety-tier system reserves for the owner -- it is the trigger +// surface for "does the system keep trying, or does it eventually open a PR" without a human approving each +// step, and is adjacent to the #1 slop-at-scale strategic risk (an autonomous fleet maximizing gate-pass rate +// can mass-produce gate-passing-but-low-value PRs). +// +// FAIL CLOSED ON AMBIGUITY: a driver run that does not complete successfully, or a self-review call that +// itself throws, is treated identically to a `SelfReviewOutcome` of `"ambiguous"` -- iterate-policy.ts's own +// precedence then abandons rather than optimistically continuing or handing off. The loop never fabricates a +// "pass" from anything other than a genuinely successful `runSelfReview` call. +// +// BOUNDED INSIDE THE LOOP: both the iteration ceiling (`input.maxIterations`) and the optional cumulative-cost +// ceiling (`input.maxTotalTurns`, summing every iteration's `turnsUsed`) are enforced here every iteration -- +// not left to an external caller to remember. A `maxIterations <= 0` input abandons immediately, before ever +// invoking the driver. +// +// AUDITABLE: every iteration's decision (continue / handoff / abandon) is recorded via the injected +// `appendAttemptLogEvent` dependency (attempt-log.ts's normalized event shape) before this function returns +// control to its caller for that iteration -- the decision trail survives independently of this function's own +// return value. A logging failure never alters the loop's decision (mirrors the governor-ledger and +// pretooluse-hook append-failure handling elsewhere in this package). + +import type { CodingAgentDriver, CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js"; +import type { CodingAgentExecutionMode } from "./coding-agent-mode.js"; +import type { AttemptLogEvent, AttemptLogEventType } from "./attempt-log.js"; +import { runSelfReview, type AttemptDiffState, type SelfReviewAdapterDeps, type SelfReviewContext, type SelfReviewVerdict } from "./self-review-adapter.js"; +import { decideNextActionWithReason, deriveSelfReviewOutcome, type IterateLoopDecision, type HandoffPacket, type IterationState, type SelfReviewOutcome } from "./iterate-policy.js"; + +/** Everything one call to {@link runIterateLoop} needs, aside from the injected {@link IterateLoopDeps}. + * Identity/context fields mirror self-review-adapter.ts's `AttemptDiffState`/`SelfReviewContext` exactly -- + * the caller assembles these from whatever Phase 2 plan/acceptance-criteria packet exists; that packet's + * exact combined shape is explicitly out of scope for this issue. */ +export type IterateLoopInput = { + attemptId: string; + workingDirectory: string; + acceptanceCriteriaPath: string; + instructions: string; + /** Resolved by the caller (e.g. the Governor chokepoint / action-mode resolution, #2340/#2342) -- this loop + * does not re-derive execution mode itself, only records whatever mode it is told. */ + mode: CodingAgentExecutionMode; + + /** Hard ceiling on iteration count, enforced every iteration via iterate-policy.ts. `<= 0` abandons before + * the first driver invocation. */ + maxIterations: number; + /** Per-iteration turn budget, passed through to each `CodingAgentDriverTask`. */ + maxTurnsPerIteration: number; + /** Optional hard ceiling on CUMULATIVE turns spent across every iteration of this attempt so far (summed + * from each iteration's `CodingAgentDriverResult.turnsUsed`). Omitted means no additional cost ceiling + * beyond what `maxIterations * maxTurnsPerIteration` already implies. */ + maxTotalTurns?: number | undefined; + + // Self-review identity fields -- mirror `AttemptDiffState`'s own identity fields (self-review-adapter.ts). + repoFullName: string; + contributorLogin: string; + title: string; + body?: string | undefined; + labels?: string[] | undefined; + linkedIssues?: number[] | undefined; + authorAssociation?: string | undefined; + /** Optional branch ref for the attempt's worktree, threaded through to a passing {@link HandoffPacket} + * unchanged -- this loop does not itself manage worktrees/branches (worktree-allocator.ts's job). */ + branchRef?: string | undefined; + + /** Repo-level self-review context (manifest, repo record, issues, pull requests, ...) -- passed through to + * `runSelfReview` unchanged every iteration. */ + reviewContext: SelfReviewContext; + + /** True when the target repo (or this contributor's history with it) has signaled it does not want + * automated contributions -- resolved by the caller (AI-policy-map / rejection-state-machine), consumed + * as-is. See iterate-policy.ts's own `IterationState.rejectionSignaled` doc comment. */ + rejectionSignaled: boolean; +}; + +export type IterateLoopDeps = { + driver: CodingAgentDriver; + runSlopAssessment: SelfReviewAdapterDeps["runSlopAssessment"]; + appendAttemptLogEvent: (event: AttemptLogEvent) => void; +}; + +/** The terminal outcomes a full loop run can end in -- never `"continue"`, which is only ever a per-iteration, + * non-terminal signal. */ +export type IterateLoopOutcome = "handoff" | "abandon"; + +export type IterateLoopIterationRecord = { + iterationNumber: number; + driverResult: CodingAgentDriverResult; + decision: IterateLoopDecision; +}; + +export type IterateLoopResult = { + outcome: IterateLoopOutcome; + finalDecision: IterateLoopDecision; + /** Count of iterations that actually invoked the driver -- `0` for the `maxIterations <= 0` immediate-abandon + * case, since the driver is never invoked there. */ + iterationsUsed: number; + /** Cumulative `turnsUsed` summed across every iteration that ran. */ + totalTurnsUsed: number; + iterations: readonly IterateLoopIterationRecord[]; + /** Populated only when `outcome === "handoff"`. */ + handoffPacket?: HandoffPacket | undefined; +}; + +function buildAttemptDiffState(input: IterateLoopInput, driverResult: CodingAgentDriverResult): AttemptDiffState { + return { + repoFullName: input.repoFullName, + contributorLogin: input.contributorLogin, + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.labels !== undefined ? { labels: input.labels } : {}), + ...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}), + ...(input.authorAssociation !== undefined ? { authorAssociation: input.authorAssociation } : {}), + changedFiles: driverResult.changedFiles.map((path) => ({ path })), + }; +} + +type SelfReviewEvaluation = { outcome: SelfReviewOutcome; verdict?: SelfReviewVerdict | undefined }; + +/** Turn one iteration's driver result into a policy-ready {@link SelfReviewOutcome}. A driver run that did not + * complete successfully, or a `runSelfReview` call that itself throws, both become `"ambiguous"` -- this loop + * never fabricates a pass/fail from anything other than a genuinely successful self-review call. */ +function evaluateSelfReviewOutcome(input: IterateLoopInput, driverResult: CodingAgentDriverResult, deps: IterateLoopDeps): SelfReviewEvaluation { + if (!driverResult.ok) { + return { + outcome: { kind: "ambiguous", reason: `driver run did not complete successfully${driverResult.error ? `: ${driverResult.error}` : "."}` }, + }; + } + try { + const verdict = runSelfReview(buildAttemptDiffState(input, driverResult), input.reviewContext, { runSlopAssessment: deps.runSlopAssessment }); + return { outcome: deriveSelfReviewOutcome(verdict), verdict }; + } catch (error) { + return { outcome: { kind: "ambiguous", reason: `self_review_error: ${error instanceof Error ? error.message : String(error)}` } }; + } +} + +/** A thrown driver error is normalized into the same `{ ok: false }` shape a driver returning gracefully would + * produce, so {@link evaluateSelfReviewOutcome} has exactly one failure path to handle, not two. */ +async function runDriverSafely(driver: CodingAgentDriver, task: CodingAgentDriverTask): Promise { + try { + return await driver.run(task); + } catch (error) { + return { ok: false, changedFiles: [], summary: "", error: `driver_threw: ${error instanceof Error ? error.message : String(error)}` }; + } +} + +function attemptLogEventTypeForDecision(decision: IterateLoopDecision): AttemptLogEventType { + if (decision.action === "continue") return "attempt_tool_edit"; + if (decision.action === "handoff") return "attempt_succeeded"; + // abandon: a deliberate early disengagement (rejection signaled, or the self-review itself was inconclusive) + // reads as aborted; a genuine failure to converge (ceiling reached, or stuck with no progress) reads as + // failed. Both are still `action: "abandon"` in the decision itself -- this is only a coarser attempt-log + // classification layered on top, for the fixed six-value ATTEMPT_LOG_EVENT_TYPES vocabulary. + if (decision.abandonReason === "rejection_signaled" || decision.abandonReason === "self_review_ambiguous") return "attempt_aborted"; + return "attempt_failed"; +} + +/** A logging failure must never crash the loop or alter its decision -- mirrors the governor-ledger and + * pretooluse-hook append-failure handling elsewhere in this package. */ +function safeAppendAttemptLogEvent(deps: IterateLoopDeps, event: AttemptLogEvent): void { + try { + deps.appendAttemptLogEvent(event); + } catch { + // Deliberately swallowed -- see doc comment above. + } +} + +function logDecision(input: IterateLoopInput, deps: IterateLoopDeps, iterationNumber: number, decision: IterateLoopDecision): void { + safeAppendAttemptLogEvent(deps, { + eventType: attemptLogEventTypeForDecision(decision), + attemptId: input.attemptId, + actionClass: "iterate_loop", + mode: input.mode, + reason: decision.reason, + payload: { + iterationNumber, + action: decision.action, + ...(decision.abandonReason !== undefined ? { abandonReason: decision.abandonReason } : {}), + }, + }); +} + +/** + * Extract the blocker codes to carry into the next iteration's no-progress comparison. Only ever called after + * `decideNextActionWithReason` has returned `"continue"` for this exact `outcome` -- that function's own + * precedence ladder short-circuits BOTH the `"ambiguous"` and `"pass"` variants (to abandon and handoff + * respectively) before ever reaching its `"continue"` fallthrough, so `outcome.kind === "fail"` is guaranteed + * whenever this is reached from the real call site below, not just the common case. + */ +function blockerCodesFromContinuingOutcome(outcome: SelfReviewOutcome): readonly string[] { + if (outcome.kind === "fail") return outcome.blockerCodes; + /* v8 ignore next -- unreachable: see this function's own doc comment above. */ + return []; +} + +function buildHandoffPacket(input: IterateLoopInput, verdict: SelfReviewVerdict, driverResult: CodingAgentDriverResult): HandoffPacket { + return { + worktreePath: input.workingDirectory, + ...(input.branchRef !== undefined ? { branchRef: input.branchRef } : {}), + diffSummary: driverResult.summary, + selfReviewVerdict: verdict, + attemptLogReference: input.attemptId, + }; +} + +function immediateAbandonNoIterationsPermitted(input: IterateLoopInput, deps: IterateLoopDeps): IterateLoopResult { + const decision: IterateLoopDecision = { + action: "abandon", + abandonReason: "max_iterations_reached", + reason: `maxIterations (${input.maxIterations}) permits no iterations; abandoning without invoking the driver.`, + }; + safeAppendAttemptLogEvent(deps, { + eventType: "attempt_aborted", + attemptId: input.attemptId, + actionClass: "iterate_loop", + mode: input.mode, + reason: decision.reason, + payload: { iterationNumber: 0, action: decision.action, abandonReason: decision.abandonReason }, + }); + return { outcome: "abandon", finalDecision: decision, iterationsUsed: 0, totalTurnsUsed: 0, iterations: [] }; +} + +/** + * Run the full create->score->self-review->decide loop for one attempt, iteration by iteration, until + * iterate-policy.ts's {@link decideNextActionWithReason} reaches a terminal `"handoff"` or `"abandon"`. + * + * Every iteration: invoke the driver, self-review the resulting diff (never fabricating a pass from a failed + * or errored driver/self-review run), consult the policy with the running iteration/cost/no-progress state, + * and record the decision via the attempt-log. `"continue"` decisions loop again; `"handoff"`/`"abandon"` + * return immediately. + */ +export async function runIterateLoop(input: IterateLoopInput, deps: IterateLoopDeps): Promise { + // Truncated toward zero rather than used as-is: a fractional maxIterations (a caller bug -- "how many times + // to run a coding agent" has no fractional meaning) would otherwise let this loop's own `for` bound and + // iterate-policy.ts's `iterationNumber >= maxIterations` ceiling check disagree by less than one iteration + // (e.g. 2.5 lets the `for` loop run a 3rd time that the ceiling check, comparing against 2.5, would not yet + // reject), silently permitting one extra iteration beyond the caller's intent. Normalizing once here keeps + // both checks watching the exact same integer ceiling. + const maxIterations = Math.max(0, Math.trunc(input.maxIterations)); + if (maxIterations <= 0) return immediateAbandonNoIterationsPermitted(input, deps); + + safeAppendAttemptLogEvent(deps, { + eventType: "attempt_started", + attemptId: input.attemptId, + actionClass: "iterate_loop", + mode: input.mode, + reason: "iterate_loop_started", + payload: { maxIterations, maxTurnsPerIteration: input.maxTurnsPerIteration }, + }); + + const iterations: IterateLoopIterationRecord[] = []; + let previousBlockerCodes: readonly string[] | null = null; + let totalTurnsUsed = 0; + + for (let iterationNumber = 1; iterationNumber <= maxIterations; iterationNumber += 1) { + const driverResult = await runDriverSafely(deps.driver, { + attemptId: input.attemptId, + workingDirectory: input.workingDirectory, + acceptanceCriteriaPath: input.acceptanceCriteriaPath, + instructions: input.instructions, + maxTurns: input.maxTurnsPerIteration, + }); + totalTurnsUsed += driverResult.turnsUsed ?? 0; + + const { outcome: selfReview, verdict } = evaluateSelfReviewOutcome(input, driverResult, deps); + + const state: IterationState = { + iterationNumber, + maxIterations, + costCeilingReached: input.maxTotalTurns !== undefined && totalTurnsUsed >= input.maxTotalTurns, + selfReview, + previousBlockerCodes, + rejectionSignaled: input.rejectionSignaled, + }; + const decision = decideNextActionWithReason(state); + logDecision(input, deps, iterationNumber, decision); + iterations.push({ iterationNumber, driverResult, decision }); + + if (decision.action === "handoff") { + // Guaranteed defined: decideNextActionWithReason only reaches `"handoff"` from `selfReview.kind === + // "pass"`, which evaluateSelfReviewOutcome only ever returns alongside a real, successfully computed + // verdict (never from the ambiguous/driver-failure path). + return { + outcome: "handoff", + finalDecision: decision, + iterationsUsed: iterationNumber, + totalTurnsUsed, + iterations, + handoffPacket: buildHandoffPacket(input, verdict as SelfReviewVerdict, driverResult), + }; + } + if (decision.action === "abandon") { + return { outcome: "abandon", finalDecision: decision, iterationsUsed: iterationNumber, totalTurnsUsed, iterations }; + } + previousBlockerCodes = blockerCodesFromContinuingOutcome(selfReview); + } + + /* v8 ignore next 8 -- unreachable in practice: decideNextActionWithReason's own `iterationNumber >= + * maxIterations` check guarantees an abandon by the time iterationNumber reaches the (now-integer, per the + * truncation above) maxIterations ceiling inside the loop above, so the for-loop above always returns. + * Retained as an explicit fail-closed fallback rather than an implicit `undefined` return, consistent with + * this package's fail-closed discipline, in case a future edit to the precedence ladder ever removes that + * guarantee. */ + const fallbackDecision: IterateLoopDecision = { action: "abandon", abandonReason: "max_iterations_reached", reason: "Iterate loop exhausted its iteration budget." }; + return { outcome: "abandon", finalDecision: fallbackDecision, iterationsUsed: maxIterations, totalTurnsUsed, iterations }; +} diff --git a/packages/gittensory-engine/src/miner/iterate-policy.ts b/packages/gittensory-engine/src/miner/iterate-policy.ts index d3dab777b2..3ddd354c82 100644 --- a/packages/gittensory-engine/src/miner/iterate-policy.ts +++ b/packages/gittensory-engine/src/miner/iterate-policy.ts @@ -11,9 +11,9 @@ // wins over EVERYTHING else, including a self-review that would otherwise pass. Continuing to submit to a // repo that has already shown it does not want automated contributions is the exact anti-pattern this // guards against, regardless of how good any individual attempt looks. -// - "reward MERGED net-positive (never submission volume)" -- the no-progress detector and iteration ceiling -// exist so a stuck loop stops wasting turns chasing a submission that was never going to land, rather than -// grinding toward *a* submission for its own sake. +// - "reward MERGED net-positive (never submission volume)" -- the no-progress detector and the iteration/cost +// ceilings exist so a stuck loop stops wasting turns (or spend) chasing a submission that was never going +// to land, rather than grinding toward *a* submission for its own sake. // // AUTONOMY DIAL (not yet wired): `src/settings/autonomy.ts`'s `resolveAutonomy`/`isActingAutonomyLevel` is the // existing reusable deny-by-default pattern this policy's eventual live autonomy-level check should consult @@ -27,7 +27,7 @@ export type IterateLoopAction = "continue" | "handoff" | "abandon"; /** Every distinct reason `decideNextAction` can abandon for -- kept as a closed literal union so a caller * recording the decision (the attempt-log primitive, per #2333) has a stable, exhaustive vocabulary. */ -export type AbandonReason = "rejection_signaled" | "self_review_ambiguous" | "max_iterations_reached" | "no_progress"; +export type AbandonReason = "rejection_signaled" | "self_review_ambiguous" | "max_iterations_reached" | "cost_ceiling_reached" | "no_progress"; /** * The self-review outcome as the policy needs it -- narrower than the full {@link SelfReviewVerdict} (self- @@ -60,6 +60,13 @@ export type IterationState = { /** Hard ceiling enforced INSIDE this policy (#2333's own deliverable: not left to an external caller to * remember to enforce). `iterationNumber >= maxIterations` abandons regardless of self-review outcome. */ maxIterations: number; + /** True when the loop's own cumulative cost ceiling (e.g. total driver turns spent across every iteration of + * this attempt so far, not just this one) has been reached or exceeded -- the loop mechanics' (#2333) OWN + * "max-cost ceiling enforced inside the loop" deliverable, alongside the iteration ceiling above. This + * policy has no notion of what "cost" means; the caller computes the boolean from whatever cost signal it + * tracks. Optional and defaults to not-reached, so `IterationState` fixtures that predate this field remain + * valid. */ + costCeilingReached?: boolean | undefined; selfReview: SelfReviewOutcome; /** The prior iteration's `fail` blocker codes, for the no-progress detector -- `null` when there is no prior * iteration to compare (the first iteration, or the prior iteration did not reach a `fail` outcome). */ @@ -116,9 +123,10 @@ function blockerSetsEqual(current: readonly string[], previous: readonly string[ * 3. `selfReview.kind === "pass"` -- the ONLY path to `"handoff"`. * 4. `iterationNumber >= maxIterations` -- abandons at the hard ceiling regardless of whether the blocker set * was still changing (genuine incremental progress does not buy unlimited iterations). - * 5. The current `fail` blocker set is identical to `previousBlockerCodes` -- abandons (no progress, stop + * 5. `costCeilingReached` -- abandons at the hard cost ceiling, same rationale as the iteration ceiling above. + * 6. The current `fail` blocker set is identical to `previousBlockerCodes` -- abandons (no progress, stop * wasting turns). - * 6. Otherwise -- continue. + * 7. Otherwise -- continue. */ export function decideNextActionWithReason(state: IterationState): IterateLoopDecision { if (state.rejectionSignaled) { @@ -141,6 +149,13 @@ export function decideNextActionWithReason(state: IterationState): IterateLoopDe reason: `Reached the iteration ceiling (${state.maxIterations}) without a clean predicted-gate pass.`, }; } + if (state.costCeilingReached === true) { + return { + action: "abandon", + abandonReason: "cost_ceiling_reached", + reason: "Reached the attempt's cost ceiling (cumulative driver spend across every iteration so far) without a clean predicted-gate pass.", + }; + } if (state.previousBlockerCodes !== null && blockerSetsEqual(state.selfReview.blockerCodes, state.previousBlockerCodes)) { return { action: "abandon", diff --git a/packages/gittensory-engine/test/iterate-loop.test.ts b/packages/gittensory-engine/test/iterate-loop.test.ts new file mode 100644 index 0000000000..360d54bb11 --- /dev/null +++ b/packages/gittensory-engine/test/iterate-loop.test.ts @@ -0,0 +1,312 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + parseFocusManifest, + runIterateLoop, + type AttemptLogEvent, + type CodingAgentDriver, + type CodingAgentDriverResult, + type IssueRecord, + type IterateLoopDeps, + type IterateLoopInput, + type PullRequestRecord, + type RepositoryRecord, + type SelfReviewContext, + type SelfReviewSlopAssessment, +} from "../dist/index.js"; + +const REPO: RepositoryRecord = { fullName: "acme/widgets", owner: "acme", name: "widgets", isInstalled: true, isRegistered: true, isPrivate: false }; + +function openIssue(number: number, title: string): IssueRecord { + return { repoFullName: "acme/widgets", number, title, state: "open", labels: [], linkedPrs: [] }; +} + +function openPr(number: number, title: string, linkedIssues: number[] = []): PullRequestRecord { + return { repoFullName: "acme/widgets", number, title, state: "open", authorLogin: "someone-else", linkedIssues, labels: [] }; +} + +const noopSlop: SelfReviewSlopAssessment = { slopRisk: 0, band: "clean", findings: [] }; + +function baseReviewContext(overrides: Partial = {}): SelfReviewContext { + return { + manifest: parseFocusManifest({ gate: { duplicates: "block", linkedIssue: "advisory" } }), + repo: REPO, + issues: [openIssue(7, "Uploads should retry on 5xx")], + pullRequests: [], + ...overrides, + }; +} + +/** Only the required identity fields set -- optional body/labels/linkedIssues/authorAssociation all omitted, so + * tests relying on this default exercise the "omitted" side of buildAttemptDiffState's conditional spreads. */ +function baseInput(overrides: Partial = {}): IterateLoopInput { + return { + attemptId: "attempt-1", + workingDirectory: "/tmp/attempt-1", + acceptanceCriteriaPath: "/tmp/attempt-1/acceptance-criteria.json", + instructions: "Add retry to the upload client", + mode: "live", + maxIterations: 3, + maxTurnsPerIteration: 20, + repoFullName: "acme/widgets", + contributorLogin: "miner1", + title: "Add retry to the upload client", + reviewContext: baseReviewContext(), + rejectionSignaled: false, + ...overrides, + }; +} + +/** A diff state that matches issue #7 cleanly (title + body + linkedIssues) with no duplicate PR in the + * reviewContext -- the "genuinely passes" shape, mirroring self-review-adapter.test.ts's own BASE_DIFF_STATE. */ +function passingInput(overrides: Partial = {}): IterateLoopInput { + return baseInput({ body: "Closes #7", linkedIssues: [7], ...overrides }); +} + +function driverReturning(result: CodingAgentDriverResult): CodingAgentDriver { + return { async run() { return result; } }; +} + +function okResult(changedFiles: string[] = ["src/upload.ts"], turnsUsed = 5): CodingAgentDriverResult { + return { ok: true, changedFiles, summary: "added retry logic", turnsUsed }; +} + +/** Collects every logged attempt-log event alongside the deps object, so a test can assert on the audit trail + * without owning its own bespoke logger. */ +function collectingDeps(overrides: Partial = {}): { deps: IterateLoopDeps; events: AttemptLogEvent[] } { + const events: AttemptLogEvent[] = []; + const deps: IterateLoopDeps = { + driver: driverReturning(okResult()), + runSlopAssessment: () => noopSlop, + appendAttemptLogEvent: (event) => { + events.push(event); + }, + ...overrides, + }; + return { deps, events }; +} + +test("barrel: the public entrypoint re-exports the iterate-loop orchestrator (#2333)", () => { + assert.equal(typeof runIterateLoop, "function"); +}); + +test("immediate abandon: maxIterations <= 0 abandons before ever invoking the driver", async () => { + let driverCalled = false; + const { deps, events } = collectingDeps({ driver: { async run() { driverCalled = true; return okResult(); } } }); + const result = await runIterateLoop(baseInput({ maxIterations: 0 }), deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "max_iterations_reached"); + assert.equal(result.iterationsUsed, 0); + assert.equal(result.totalTurnsUsed, 0); + assert.deepEqual(result.iterations, []); + assert.equal(driverCalled, false, "the driver must never run when no iterations are permitted"); + assert.equal(events.length, 1, "the immediate-abandon path still records exactly one audit event"); + assert.equal(events[0]?.eventType, "attempt_aborted"); +}); + +test("handoff: a clean predicted-gate pass on the first iteration hands off, with a full HandoffPacket", async () => { + const { deps, events } = collectingDeps({ driver: driverReturning(okResult(["src/upload.ts"], 5)) }); + const result = await runIterateLoop(passingInput({ maxIterations: 3 }), deps); + + assert.equal(result.outcome, "handoff"); + assert.equal(result.finalDecision.action, "handoff"); + assert.equal(result.iterationsUsed, 1); + assert.equal(result.totalTurnsUsed, 5); + assert.equal(result.iterations.length, 1); + assert.ok(result.handoffPacket); + assert.equal(result.handoffPacket?.worktreePath, "/tmp/attempt-1"); + assert.equal(result.handoffPacket?.branchRef, undefined, "branchRef is omitted (not just undefined-valued) when the input never set one"); + assert.equal(result.handoffPacket?.diffSummary, "added retry logic"); + assert.equal(result.handoffPacket?.selfReviewVerdict.predictedGateVerdict.conclusion, "success"); + assert.equal(result.handoffPacket?.selfReviewVerdict.passesPredictedGate, true); + assert.equal(result.handoffPacket?.attemptLogReference, "attempt-1"); + + assert.equal(events.filter((event) => event.eventType === "attempt_started").length, 1); + assert.equal(events.filter((event) => event.eventType === "attempt_succeeded").length, 1); +}); + +test("handoff: a caller-supplied branchRef is threaded through to the HandoffPacket unchanged", async () => { + const { deps } = collectingDeps({ driver: driverReturning(okResult()) }); + const result = await runIterateLoop(passingInput({ branchRef: "miner/attempt-1" }), deps); + + assert.equal(result.outcome, "handoff"); + assert.equal(result.handoffPacket?.branchRef, "miner/attempt-1"); +}); + +test("handoff: labels and authorAssociation, when set, are threaded into the self-review verdict identically to a direct call", async () => { + const { deps } = collectingDeps({ driver: driverReturning(okResult()) }); + const result = await runIterateLoop( + passingInput({ labels: ["gittensor:feature"], authorAssociation: "CONTRIBUTOR" }), + deps, + ); + assert.equal(result.outcome, "handoff"); +}); + +test("runs with only the required identity fields set, without crashing regardless of the resulting verdict", async () => { + const { deps } = collectingDeps({ driver: driverReturning(okResult()) }); + const result = await runIterateLoop(baseInput({ maxIterations: 1 }), deps); + + assert.equal(result.iterationsUsed, 1); + assert.ok(result.outcome === "handoff" || result.outcome === "abandon"); +}); + +test("continue then handoff: a duplicate-PR blocker on iteration 1 clears by iteration 2, and the loop hands off", async () => { + const pullRequests: PullRequestRecord[] = [openPr(42, "Retry uploads on 5xx responses", [7])]; + let callCount = 0; + const driver: CodingAgentDriver = { + async run() { + callCount += 1; + if (callCount === 1) return okResult(["src/upload.ts"], 3); + pullRequests.length = 0; + return okResult(["src/upload.ts"], 4); + }, + }; + const { deps, events } = collectingDeps({ driver }); + const input = passingInput({ maxIterations: 5, reviewContext: baseReviewContext({ pullRequests }) }); + const result = await runIterateLoop(input, deps); + + assert.equal(result.outcome, "handoff"); + assert.equal(result.iterationsUsed, 2); + assert.equal(callCount, 2); + assert.equal(result.totalTurnsUsed, 7); + assert.equal(events.filter((event) => event.eventType === "attempt_tool_edit").length, 1, "iteration 1's continue is logged as attempt_tool_edit"); +}); + +test("abandon (no_progress): a self-review that keeps failing with the identical blocker set stops iterating", async () => { + const pullRequests: PullRequestRecord[] = [openPr(42, "Retry uploads on 5xx responses", [7])]; + const { deps, events } = collectingDeps({ driver: driverReturning(okResult(["src/upload.ts"], 2)) }); + const input = passingInput({ maxIterations: 5, reviewContext: baseReviewContext({ pullRequests }) }); + const result = await runIterateLoop(input, deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "no_progress"); + assert.equal(result.iterationsUsed, 2, "iteration 1 continues (no prior to compare); iteration 2 sees the identical blocker set"); + assert.equal(events.filter((event) => event.eventType === "attempt_failed").length, 1); +}); + +test("abandon (max_iterations_reached): the loop's own ceiling stops it even on the very first iteration", async () => { + const pullRequests: PullRequestRecord[] = [openPr(42, "Retry uploads on 5xx responses", [7])]; + let callCount = 0; + const { deps } = collectingDeps({ driver: { async run() { callCount += 1; return okResult(); } } }); + const input = passingInput({ maxIterations: 1, reviewContext: baseReviewContext({ pullRequests }) }); + const result = await runIterateLoop(input, deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "max_iterations_reached"); + assert.equal(callCount, 1, "the driver runs exactly once, for the one permitted iteration"); +}); + +test("a fractional maxIterations truncates toward the lower integer, not silently allowing a partial extra iteration", async () => { + const pullRequests: PullRequestRecord[] = [openPr(42, "Retry uploads on 5xx responses", [7])]; + let callCount = 0; + const { deps } = collectingDeps({ driver: { async run() { callCount += 1; return okResult(); } } }); + const input = passingInput({ maxIterations: 1.5, reviewContext: baseReviewContext({ pullRequests }) }); + const result = await runIterateLoop(input, deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "max_iterations_reached"); + assert.equal(callCount, 1, "1.5 truncates to 1, not 2 -- the driver must not run a fractional extra iteration"); + assert.equal(result.iterationsUsed, 1); +}); + +test("abandon (cost_ceiling_reached): the cumulative-turns ceiling stops the loop even with iterations still available", async () => { + const pullRequests: PullRequestRecord[] = [openPr(42, "Retry uploads on 5xx responses", [7])]; + const { deps } = collectingDeps({ driver: driverReturning(okResult(["src/upload.ts"], 50)) }); + const input = passingInput({ maxIterations: 10, maxTotalTurns: 20, reviewContext: baseReviewContext({ pullRequests }) }); + const result = await runIterateLoop(input, deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "cost_ceiling_reached"); + assert.equal(result.iterationsUsed, 1); +}); + +test("continue: maxTotalTurns omitted never trips the cost ceiling, regardless of turns spent", async () => { + const { deps } = collectingDeps({ driver: driverReturning(okResult(["src/upload.ts"], 1_000_000)) }); + const result = await runIterateLoop(passingInput({ maxTotalTurns: undefined }), deps); + assert.equal(result.outcome, "handoff"); +}); + +test("abandon (rejection_signaled): wins even over a self-review that would otherwise cleanly pass", async () => { + const { deps, events } = collectingDeps({ driver: driverReturning(okResult()) }); + const result = await runIterateLoop(passingInput({ rejectionSignaled: true }), deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "rejection_signaled"); + assert.equal(result.iterationsUsed, 1, "the driver still runs once before the policy is consulted -- rejection can arrive mid-loop, not just up front"); + assert.equal(events.filter((event) => event.eventType === "attempt_aborted").length, 1); +}); + +test("abandon (self_review_ambiguous): a driver run that completes but reports ok:false, with an error message set", async () => { + const { deps } = collectingDeps({ driver: driverReturning({ ok: false, changedFiles: [], summary: "", error: "worktree corrupted" }) }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "self_review_ambiguous"); + assert.match(result.finalDecision.reason, /worktree corrupted/); +}); + +test("abandon (self_review_ambiguous): a driver run that reports ok:false with NO error message still formats a reason", async () => { + const { deps } = collectingDeps({ driver: driverReturning({ ok: false, changedFiles: [], summary: "" }) }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.equal(result.finalDecision.abandonReason, "self_review_ambiguous"); + assert.match(result.finalDecision.reason, /driver run did not complete successfully\./); +}); + +test("abandon (self_review_ambiguous): the driver throwing a real Error is normalized, not left to propagate", async () => { + const { deps } = collectingDeps({ + driver: { async run() { throw new Error("subprocess crashed"); } }, + }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.equal(result.finalDecision.abandonReason, "self_review_ambiguous"); + assert.match(result.finalDecision.reason, /driver_threw: subprocess crashed/); +}); + +test("abandon (self_review_ambiguous): the driver throwing a non-Error value still formats a reason via String(error)", async () => { + const { deps } = collectingDeps({ + driver: { async run() { throw "disk full"; } }, + }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.match(result.finalDecision.reason, /driver_threw: disk full/); +}); + +test("abandon (self_review_ambiguous): runSelfReview itself throwing a real Error is caught, not left to propagate", async () => { + const { deps } = collectingDeps({ + driver: driverReturning(okResult()), + runSlopAssessment: () => { + throw new Error("slop assessment blew up"); + }, + }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.equal(result.finalDecision.abandonReason, "self_review_ambiguous"); + assert.match(result.finalDecision.reason, /self_review_error: slop assessment blew up/); +}); + +test("abandon (self_review_ambiguous): runSelfReview throwing a non-Error value still formats a reason via String(error)", async () => { + const { deps } = collectingDeps({ + driver: driverReturning(okResult()), + runSlopAssessment: () => { + throw "synthetic non-Error failure"; + }, + }); + const result = await runIterateLoop(passingInput({ maxIterations: 1 }), deps); + + assert.match(result.finalDecision.reason, /self_review_error: synthetic non-Error failure/); +}); + +test("a logging failure never crashes the loop or alters its decision", async () => { + const { deps } = collectingDeps({ + driver: driverReturning(okResult()), + appendAttemptLogEvent: () => { + throw new Error("ledger unavailable"); + }, + }); + const result = await runIterateLoop(passingInput(), deps); + + assert.equal(result.outcome, "handoff", "the tool call is still decided correctly even though every audit write failed"); +}); diff --git a/packages/gittensory-engine/test/iterate-policy.test.ts b/packages/gittensory-engine/test/iterate-policy.test.ts index 8b79eab7a7..dbd7f550cb 100644 --- a/packages/gittensory-engine/test/iterate-policy.test.ts +++ b/packages/gittensory-engine/test/iterate-policy.test.ts @@ -92,6 +92,47 @@ test("continue: one iteration below the ceiling still continues", () => { assert.equal(decideNextAction(state), "continue"); }); +test("abandon (cost_ceiling_reached): the loop's own cumulative cost ceiling stops the loop even mid-progress", () => { + const state = baseState({ + iterationNumber: 2, + maxIterations: 10, + costCeilingReached: true, + selfReview: { kind: "fail", blockerCodes: ["a_brand_new_blocker_never_seen_before"] }, + previousBlockerCodes: ["missing_linked_issue"], + }); + const decision = decideNextActionWithReason(state); + assert.equal(decision.action, "abandon"); + assert.equal(decision.abandonReason, "cost_ceiling_reached"); +}); + +test("abandon (cost_ceiling_reached): checked before no_progress, but after the iteration ceiling -- max_iterations still wins when both fire", () => { + const state = baseState({ iterationNumber: 5, maxIterations: 5, costCeilingReached: true }); + const decision = decideNextActionWithReason(state); + assert.equal(decision.abandonReason, "max_iterations_reached"); +}); + +test("continue: costCeilingReached omitted (undefined) does not itself trigger an abandon -- the field is optional", () => { + const state = baseState({ + iterationNumber: 2, + maxIterations: 10, + selfReview: { kind: "fail", blockerCodes: ["a_new_code"] }, + previousBlockerCodes: ["a_different_code"], + }); + assert.equal(state.costCeilingReached, undefined); + assert.equal(decideNextAction(state), "continue"); +}); + +test("continue: costCeilingReached explicitly false behaves identically to omitted", () => { + const state = baseState({ + iterationNumber: 2, + maxIterations: 10, + costCeilingReached: false, + selfReview: { kind: "fail", blockerCodes: ["a_new_code"] }, + previousBlockerCodes: ["a_different_code"], + }); + assert.equal(decideNextAction(state), "continue"); +}); + test("abandon (no_progress): an identical blocker set to the prior iteration stops wasting turns", () => { const state = baseState({ iterationNumber: 2,