diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index 427e7f576..640c41ccb 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -1878,6 +1878,142 @@ describe("MissionExecutionLoop", () => { }); }); + // ── premerge guard (fail verdict while the linked task is unmerged) ────── + + describe("premerge guard", () => { + function primeFailVerdict() { + const failResponse = JSON.stringify({ + status: "fail", + assertions: [{ assertionId: "CA-1", passed: false, message: "Failed", expected: "ok", actual: "not ok" }], + summary: "Assertion failed", + }); + mockSessionHolder.session.state.messages = [ + { role: "user", content: "Validate this" }, + { role: "assistant", content: failResponse }, + ]; + } + + function primeFeature() { + const feature = createMockFeature({ + loopState: "implementing", + taskId: "FN-001", + id: "F-001", + implementationAttemptCount: 1, + }); + missionStore._setFeature(feature); + missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); + missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(1)); + return feature; + } + + it("should defer a fail to inconclusive while the linked task is not done/archived", async () => { + primeFeature(); + primeFailVerdict(); + + // The linked task is still in review — its code has not merged yet, so + // the validator judged a checkout that predates the work. + taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [], column: "in-review" }); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + const emitSpy = vi.spyOn(loop, "emit"); + loop.start(); + + await loop.processTaskOutcome("FN-001"); + + // Routed to the inconclusive outcome — no Fix Feature minted (R21) + expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled(); + expect(missionStore.completeValidatorRun).toHaveBeenCalledWith( + expect.any(String), + "blocked", + expect.stringContaining("code not merged yet"), + ); + expect(emitSpy).toHaveBeenCalledWith( + "validation:inconclusive", + expect.objectContaining({ + featureId: "F-001", + reason: expect.stringContaining('"in-review"'), + }), + ); + expect(emitSpy).not.toHaveBeenCalledWith("validation:failed", expect.anything()); + + // The infra-failure marker keeps deferred fails separable from real ones + expect(missionStore.logMissionEvent).toHaveBeenCalledWith( + expect.any(String), + "warning", + expect.stringContaining("Verification inconclusive"), + expect.objectContaining({ + code: "verification_inconclusive", + outcome: "inconclusive", + infraFailure: true, + }), + ); + }); + + it("should run the normal fail path once the linked task is done", async () => { + primeFeature(); + primeFailVerdict(); + + taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [], column: "done" }); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + const emitSpy = vi.spyOn(loop, "emit"); + loop.start(); + + await loop.processTaskOutcome("FN-001"); + + expect(missionStore.createGeneratedFixFeature).toHaveBeenCalledWith( + "F-001", + expect.any(String), + expect.arrayContaining(["CA-1"]), + expect.any(String), + ); + expect(emitSpy).toHaveBeenCalledWith( + "validation:failed", + expect.objectContaining({ featureId: "F-001" }), + ); + expect(emitSpy).not.toHaveBeenCalledWith("validation:inconclusive", expect.anything()); + }); + + it("should fail open (normal fail path) when the linked task cannot be read", async () => { + primeFeature(); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + // Bypass the AI session (runValidation also reads the task, without a + // catch) so the rejecting getTask below only exercises the guard. + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + status: "fail", + assertions: [{ assertionId: "CA-1", passed: false, message: "Failed", expected: "ok", actual: "not ok" }], + summary: "Assertion failed", + }); + taskStore.getTask = vi.fn().mockRejectedValue(new Error("store unavailable")); + + const emitSpy = vi.spyOn(loop, "emit"); + loop.start(); + + await loop.processTaskOutcome("FN-001"); + + // Unknown task state must never suppress a fail — only defer on + // affirmative evidence of an unmerged column. + expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith( + "validation:failed", + expect.objectContaining({ featureId: "F-001" }), + ); + }); + }); + // ── handleValidationBlocked ─────────────────────────────────────────────── describe("handleValidationBlocked", () => { diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 763781e2d..e2265f48d 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -498,7 +498,23 @@ export class MissionExecutionLoop extends EventEmitter { if (result.status === "pass") { await this.handleValidationPass(feature.id, run.id, result.summary); } else if (result.status === "fail") { - await this.handleValidationFail(feature.id, run.id, result); + // A "fail" verdict is only trustworthy once the linked task's code has + // actually landed (done/archived). If the task is still mid-pipeline + // (in-review PR, external merge train, deferred base sync), the + // validator judged a checkout that predates the merge — route to the + // inconclusive outcome (R21, no Fix Feature) and let a later validation + // judge the merged code. Missing task / unknown column falls through to + // the normal fail handling (defer only on affirmative evidence). + const premergeColumn = await this.getPremergeTaskColumn(feature.taskId); + if (premergeColumn) { + await this.handleValidationInconclusive( + feature.id, + run.id, + `linked task ${feature.taskId} is still "${premergeColumn}" (code not merged yet) — validation deferred`, + ); + } else { + await this.handleValidationFail(feature.id, run.id, result); + } } else if (result.status === "inconclusive") { // R21 — "verification could not run" is distinct from "behavior observed // wrong". An infra-driven inconclusive (no isolating backend, timeout, @@ -516,6 +532,21 @@ export class MissionExecutionLoop extends EventEmitter { } } + /** + * Resolve the linked task's column when it affirmatively shows the task has + * NOT completed yet (any column other than "done"/"archived"). Returns null + * when the task is completed, missing, unlinked, or unreadable — i.e. every + * case where a fail verdict should be trusted. Fails open on purpose: the + * guard may only ever defer a fail, never suppress one on missing data. + */ + private async getPremergeTaskColumn(taskId: string | undefined): Promise { + if (!taskId) return null; + const linkedTask = await this.taskStore.getTask(taskId).catch(() => null); + const column = linkedTask?.column; + if (!column || column === "done" || column === "archived") return null; + return column; + } + /** * Run the validation AI session for a feature. *