fix(approvals): a REJECTED decision must not satisfy the stage approval gate on retry
This commit is contained in:
@@ -209,6 +209,71 @@ class FreestyleApprovalGateTest {
|
||||
runJob.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prior REJECTED decision does not satisfy the gate — it re-prompts, not runs unapproved`(): Unit =
|
||||
runBlocking {
|
||||
val sessionId = SessionId("freestyle-gate-reject")
|
||||
// Pre-seed the log as if the architect gate had already been prompted and REJECTED (the
|
||||
// retry/resume situation): a matching request + a REJECTED decision for it. The pre-fix
|
||||
// predicate matched any decision for the request and skipped the gate, running unapproved.
|
||||
val requestId = com.correx.core.events.types.ApprovalRequestId("seeded-req")
|
||||
eventStore.append(
|
||||
com.correx.testing.fixtures.EventFixtures.newEvent(
|
||||
com.correx.core.events.types.EventId("seed-req"),
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = com.correx.core.events.types.ValidationReportId("vr"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = architectStage,
|
||||
projectId = null,
|
||||
toolName = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
eventStore.append(
|
||||
com.correx.testing.fixtures.EventFixtures.newEvent(
|
||||
com.correx.core.events.types.EventId("seed-dec"),
|
||||
sessionId,
|
||||
com.correx.core.events.events.ApprovalDecisionResolvedEvent(
|
||||
decisionId = com.correx.core.events.types.ApprovalDecisionId("seeded-dec"),
|
||||
requestId = requestId,
|
||||
outcome = ApprovalOutcome.REJECTED,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = Tier.T2,
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = "operator said no",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val orchestrator = buildOrchestrator()
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||
val runJob = launch { orchestrator.run(sessionId, freestyleGraph(), config) }
|
||||
|
||||
// The gate must re-prompt: a SECOND architect approval request appears (the seeded one is
|
||||
// the first). With the bug the seeded REJECTED decision satisfied the gate and no new
|
||||
// request was emitted — the architect ran and the workflow completed unapproved.
|
||||
withTimeout(5_000) {
|
||||
while (
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? ApprovalRequestedEvent }
|
||||
.count { it.stageId == architectStage && it.toolName == null } < 2
|
||||
) {
|
||||
yield()
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
eventStore.read(sessionId).none { it.payload is WorkflowCompletedEvent },
|
||||
"Workflow must not complete off a REJECTED decision",
|
||||
)
|
||||
runJob.cancel()
|
||||
runJob.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("freestyle-gate-2")
|
||||
|
||||
Reference in New Issue
Block a user