diff --git a/packages/gittensory-engine/src/miner/iterate-loop.ts b/packages/gittensory-engine/src/miner/iterate-loop.ts index ef54da30c..29ef021dc 100644 --- a/packages/gittensory-engine/src/miner/iterate-loop.ts +++ b/packages/gittensory-engine/src/miner/iterate-loop.ts @@ -27,7 +27,8 @@ // 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 { codingAgentModeExecutes, type CodingAgentExecutionMode } from "./coding-agent-mode.js"; +import { invokeCodingAgentDriver } from "./coding-agent-invoke.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"; @@ -139,10 +140,16 @@ function evaluateSelfReviewOutcome(input: IterateLoopInput, driverResult: Coding } /** 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 { + * produce, so {@link evaluateSelfReviewOutcome} has exactly one failure path to handle, not two. Non-live modes + * are also resolved here, at the driver boundary, so paused/dry-run attempts never spawn the underlying agent. */ +async function runDriverSafely(input: IterateLoopInput, deps: IterateLoopDeps, task: CodingAgentDriverTask): Promise { + if (!codingAgentModeExecutes(input.mode)) { + return invokeCodingAgentDriver(deps.driver, input.mode, task, { + append: (event) => safeAppendAttemptLogEvent(deps, event), + }); + } try { - return await driver.run(task); + return await deps.driver.run(task); } catch (error) { return { ok: false, changedFiles: [], summary: "", error: `driver_threw: ${error instanceof Error ? error.message : String(error)}` }; } @@ -257,7 +264,7 @@ export async function runIterateLoop(input: IterateLoopInput, deps: IterateLoopD let totalTurnsUsed = 0; for (let iterationNumber = 1; iterationNumber <= maxIterations; iterationNumber += 1) { - const driverResult = await runDriverSafely(deps.driver, { + const driverResult = await runDriverSafely(input, deps, { attemptId: input.attemptId, workingDirectory: input.workingDirectory, acceptanceCriteriaPath: input.acceptanceCriteriaPath, diff --git a/packages/gittensory-engine/test/iterate-loop.test.ts b/packages/gittensory-engine/test/iterate-loop.test.ts index 360d54bb1..13d1189b5 100644 --- a/packages/gittensory-engine/test/iterate-loop.test.ts +++ b/packages/gittensory-engine/test/iterate-loop.test.ts @@ -106,6 +106,34 @@ test("immediate abandon: maxIterations <= 0 abandons before ever invoking the dr assert.equal(events[0]?.eventType, "attempt_aborted"); }); +test("paused mode: does not invoke the coding-agent driver (regression for pause gate bypass)", async () => { + let driverCalled = false; + const { deps, events } = collectingDeps({ driver: { async run() { driverCalled = true; return okResult(); } } }); + const result = await runIterateLoop(passingInput({ mode: "paused", maxIterations: 1 }), deps); + + assert.equal(driverCalled, false, "paused is a kill switch and must not call driver.run"); + assert.equal(result.outcome, "abandon"); + assert.equal(result.finalDecision.abandonReason, "self_review_ambiguous"); + assert.equal(result.iterationsUsed, 1); + assert.equal(result.totalTurnsUsed, 0); + assert.equal(result.iterations[0]?.driverResult.error, "coding_agent_paused"); + assert.equal(events.some((event) => event.eventType === "attempt_aborted" && event.actionClass === "codegen" && event.reason === "coding_agent_paused"), true); +}); + +test("dry_run mode: records a shadow coding-agent invocation without calling the driver (regression for dry-run gate bypass)", async () => { + let driverCalled = false; + const { deps, events } = collectingDeps({ driver: { async run() { driverCalled = true; return okResult(); } } }); + const result = await runIterateLoop(passingInput({ mode: "dry_run", maxIterations: 1 }), deps); + + assert.equal(driverCalled, false, "dry-run must not spawn the underlying coding-agent session"); + assert.equal(result.outcome, "handoff"); + assert.equal(result.iterationsUsed, 1); + assert.equal(result.totalTurnsUsed, 0); + assert.equal(result.iterations[0]?.driverResult.ok, true); + assert.match(result.iterations[0]?.driverResult.summary ?? "", /dry-run: would invoke coding agent/); + assert.equal(events.some((event) => event.eventType === "attempt_shadow" && event.actionClass === "codegen" && event.mode === "dry_run"), true); +}); + 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);