fix(approvals): a REJECTED decision must not satisfy the stage approval gate on retry
This commit is contained in:
+5
-2
@@ -661,8 +661,11 @@ class DefaultSessionOrchestrator(
|
|||||||
.map { it.requestId }
|
.map { it.requestId }
|
||||||
.toSet()
|
.toSet()
|
||||||
stageRequestIds.isNotEmpty() && events.any {
|
stageRequestIds.isNotEmpty() && events.any {
|
||||||
(it.payload as? ApprovalDecisionResolvedEvent)
|
val decision = it.payload as? ApprovalDecisionResolvedEvent
|
||||||
?.requestId in stageRequestIds
|
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
|
||||||
|
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
|
||||||
|
decision?.requestId in stageRequestIds &&
|
||||||
|
decision?.outcome != ApprovalOutcome.REJECTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!alreadyApproved) {
|
if (!alreadyApproved) {
|
||||||
|
|||||||
@@ -209,6 +209,71 @@ class FreestyleApprovalGateTest {
|
|||||||
runJob.join()
|
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
|
@Test
|
||||||
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
|
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
|
||||||
val sessionId = SessionId("freestyle-gate-2")
|
val sessionId = SessionId("freestyle-gate-2")
|
||||||
|
|||||||
Reference in New Issue
Block a user