From 8cc418a3812c57687d3b49849189006994896be1 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 21 Jul 2026 02:22:09 +0400 Subject: [PATCH] feat(orchestration): escalate repeated scope/manifest write-block to user approval (#301) Small models frequently fail to comply with the WRITE_SCOPE/PATH_OUTSIDE_MANIFEST remediation ("add its path via task_update affected_paths") and instead thrash the same out-of-scope write call turn after turn, burning the session without ever completing. After `tuning.escalateScopeAfterN` (default 3) consecutive rejections of the SAME path for a scope/manifest reason, the orchestrator now reaches back to the FIRST rejected invocation of that path (its pristine, pre-degraded arguments), and routes it through the existing approval/pause flow instead of rejecting again. On approval, a new WriteScopeGrantedEvent widens the effective write manifest for that path for the rest of the session (mirroring how OutsidePathAccessGrantedEvent already widens out-of-workspace reads) and the first attempt's write is executed. On denial, the call is rejected as before. escalateScopeAfterN = 0 preserves the old hard-block-forever behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GeyGFXczJb8RUWGBKmkm6G --- .../core/events/events/OrchestrationEvents.kt | 15 ++ .../orchestration/OrchestrationTuning.kt | 8 + .../SessionOrchestratorToolExec.kt | 201 +++++++++++++++++- .../SessionOrchestratorWorkspace.kt | 51 +++++ .../src/test/kotlin/ToolCallGateTest.kt | 175 +++++++++++++++ 5 files changed, 448 insertions(+), 2 deletions(-) diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt index 9f9e924c..ca447c70 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt @@ -53,6 +53,21 @@ data class OutsidePathAccessGrantedEvent( val path: String, ) : EventPayload +/** + * Records that the operator approved widening the write scope/manifest to admit [path], after + * the same path was rejected `escalate_scope_after_n` times in a row (small models frequently + * fail to comply with the WRITE_SCOPE/PATH_OUTSIDE_MANIFEST remediation and thrash instead — + * see #301). Folded the same way [OutsidePathAccessGrantedEvent] widens out-of-workspace reads: + * subsequent writes to this path this session are admitted without re-prompting. + */ +@Serializable +@SerialName("WriteScopeGranted") +data class WriteScopeGrantedEvent( + val sessionId: SessionId, + val stageId: StageId, + val path: String, +) : EventPayload + @Serializable @SerialName("OrchestrationPaused") data class OrchestrationPausedEvent( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt index 891dab48..9aa08e0f 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt @@ -41,4 +41,12 @@ data class OrchestrationTuning( val recoveryRouteBudget: Int = 2, /** Budget for tier-2 intent-holder arbiter re-routing. */ val intentRouteBudget: Int = 2, + /** + * After this many consecutive WRITE_SCOPE/PATH_OUTSIDE_MANIFEST rejections of the SAME path in + * a session, stop hard-rejecting and escalate to user approval instead (#301) — small models + * frequently fail to comply with the remediation and thrash rather than widen scope themselves. + * 0 disables escalation (falls back to the hard-block-forever behavior). A headless session + * (no approver connected) auto-rejects the escalated prompt rather than hanging. + */ + val escalateScopeAfterN: Int = 3, ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt index a69f68ee..2496e810 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt @@ -31,6 +31,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest +import com.correx.core.events.events.WriteScopeGrantedEvent import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskSummary import com.correx.core.toolintent.ToolCallAssessmentInput @@ -218,14 +219,36 @@ internal suspend fun SessionOrchestrator.dispatchToolCalls( // Per-task write scope: while a task is claimed, narrow the manifest to its affected // paths (recorded on the task) so the implementer can't write outside its unit of work. // Falls back to the stage's static manifest when nothing is claimed. - val effectiveManifest = taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() } - ?: stageConfig.writeManifest + val escalatedScope = escalatedWriteScopePaths(sessionId) + val effectiveManifest = ( + taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() } + ?: stageConfig.writeManifest + ) + escalatedScope val plane2Risk: RiskSummary? = runPlane2Assessment( sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives, effectiveManifest, )?.let { assessment -> if (assessment.recommendedAction == RiskAction.BLOCK) { val rationale = assessment.rationale.joinToString("; ") + // #301: a write repeatedly rejected for being outside the claimed task's scope or + // the stage's manifest — never for anything else — escalates to user approval + // after N same-path rejections instead of hard-blocking forever. Small models + // routinely fail to comply with the "add its path via task_update" remediation and + // thrash the same call rather than widen scope themselves. + val escalationPath = (parameters["path"] as? String) + ?.takeIf { isScopeBlockRationale(rationale) } + val escalateAfterN = tuning.escalateScopeAfterN + if (escalationPath != null && escalateAfterN > 0) { + val priorRejections = priorScopeRejections(sessionId, escalationPath) + if (priorRejections.size >= escalateAfterN) { + val firstAttempt = priorRejections.first().first + return@flatMap escalateWriteScopeBlock( + sessionId, stageId, invocationId, toolCall, tier, escalationPath, + firstAttempt, effectives, toolCallReasoning, approvalMode, assessment, + fileWrittenSlots, + ) + } + } // On a bad-path block, point the model at the closest real file so it fixes the // path instead of retrying the same wrong guess (deep package paths are easy to // misremember). Only fires when we can name a concrete match from the repo map. @@ -509,6 +532,180 @@ internal suspend fun SessionOrchestrator.dispatchToolCalls( } } +private fun parametersToArgumentsJson(parameters: Map): String = + kotlinx.serialization.json.buildJsonObject { + parameters.forEach { (k, v) -> + when (v) { + is List<*> -> put( + k, + kotlinx.serialization.json.buildJsonArray { + v.forEach { add(kotlinx.serialization.json.JsonPrimitive(it.toString())) } + }, + ) + else -> put(k, kotlinx.serialization.json.JsonPrimitive(v.toString())) + } + } + }.toString() + +/** + * #301: the Nth same-path WRITE_SCOPE/PATH_OUTSIDE_MANIFEST rejection routes into the existing + * approval/pause flow instead of hard-blocking again. [firstAttempt] is the FIRST invocation that + * was rejected for this path/reason (reached back into via [priorScopeRejections]) — its pristine + * arguments are what gets previewed and, on approval, executed; later attempts this session may + * have degraded (e.g. the model giving up and calling `task_update(action="block")` instead), so + * replaying those would not fulfil the original intent. + */ +internal suspend fun SessionOrchestrator.escalateWriteScopeBlock( + sessionId: SessionId, + stageId: StageId, + invocationId: ToolInvocationId, + toolCall: ToolCallRequest, + tier: Tier, + path: String, + firstAttempt: ToolInvocationRequestedEvent, + effectives: RunEffectives, + toolCallReasoning: String?, + approvalMode: ApprovalMode, + plane2Risk: RiskSummary?, + fileWrittenSlots: List, +): List { + val sourceId = toolCall.id ?: invocationId.value + val assistantEntry = ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L2, + sourceType = "assistantToolCall", + sourceId = sourceId, + content = Json.encodeToString(ToolCallRequest.serializer(), toolCall), + tokenEstimate = estimateTokens(toolCall.function.arguments), + role = EntryRole.ASSISTANT, + reasoning = toolCallReasoning, + ) + suspend fun rejected(reason: String): List { + blockTaskOnScopeRejection(sessionId, toolCall.function.name, reason) + emit( + sessionId, + ToolExecutionRejectedEvent( + invocationId = invocationId, + sessionId = sessionId, + toolName = toolCall.function.name, + tier = tier, + reason = reason, + ), + ) + return listOf( + assistantEntry, + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L2, + sourceType = "toolResult", + sourceId = sourceId, + content = "BLOCKED: $reason", + tokenEstimate = estimateTokens(reason), + role = EntryRole.TOOL, + ), + ) + } + + val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) } + val approvalCtx = ApprovalContext( + identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId), + mode = approvalMode, + ) + val requestId = ApprovalRequestId(UUID.randomUUID().toString()) + val toolPreview = computeToolPreview( + firstAttempt.toolName, firstAttempt.request.parameters, effectives.policy?.workspaceRoot, + ) + val previewArguments = parametersToArgumentsJson(firstAttempt.request.parameters) + val domainRequest = DomainApprovalRequest( + id = requestId, + tier = firstAttempt.tier, + validationReportId = ValidationReportId(UUID.randomUUID().toString()), + riskSummaryId = null, + timestamp = Clock.System.now(), + toolName = firstAttempt.toolName, + preview = toolPreview ?: previewArguments.take(200), + ) + val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values + val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values + val activeGrants = (sessionGrants + ledgerGrants).toList() + val engineDecision = approvalEngine.evaluate(domainRequest, approvalCtx, activeGrants, Clock.System.now()) + val approved: Boolean + val denyReason: String? + if (engineDecision.state == ApprovalStatus.COMPLETED) { + // Headless/no-approver sessions resolve here (e.g. approvalMode DENY auto-completes to a + // denial) — the escalation never hangs waiting on a human who isn't connected. + emitDecisionResolved(sessionId, domainRequest, engineDecision) + approved = engineDecision.isApproved + denyReason = engineDecision.reason + } else { + val deferred = CompletableDeferred() + pendingApprovals[requestId] = deferred + emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")) + emit( + sessionId, + ApprovalRequestedEvent( + requestId = requestId, + tier = firstAttempt.tier, + validationReportId = domainRequest.validationReportId, + riskSummaryId = null, + riskSummary = plane2Risk, + sessionId = sessionId, + stageId = stageId, + projectId = null, + toolName = firstAttempt.toolName, + preview = toolPreview ?: previewArguments.take(200), + ), + ) + val userDecision = try { + deferred.await() + } finally { + pendingApprovals.remove(requestId) + } + emitDecisionResolved(sessionId, domainRequest, userDecision) + emit(sessionId, OrchestrationResumedEvent(sessionId, stageId)) + approved = userDecision.isApproved + denyReason = userDecision.reason + } + if (!approved) { + return rejected(denyReason ?: "scope-widening request denied") + } + + // Approved: widen the write scope for this path for the rest of the session (future writes to + // it skip straight past the risk plane, mirroring OutsidePathAccessGrantedEvent), then execute + // the FIRST rejected attempt's pristine write rather than the model's current — possibly + // degraded — call. + emit(sessionId, WriteScopeGrantedEvent(sessionId, stageId, path)) + val executor = effectives.executor ?: return rejected("no executor available to apply the approved write") + val tool = effectives.registry?.resolve(firstAttempt.toolName) + val result = executor.execute(firstAttempt.request) + val rendered = renderToolResult(firstAttempt.toolName, tool, result) + recordToolExecution( + sessionId, + stageId, + toolCall.copy(function = toolCall.function.copy(name = firstAttempt.toolName)), + invocationId, + tier, + result, + tool as? FileAffectingTool, + firstAttempt.request, + fileWrittenSlots, + rendered.fullOutputHash, + ) + return listOf( + assistantEntry, + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L2, + sourceType = "toolResult", + sourceId = sourceId, + content = "APPROVED: the user widened write scope to admit '$path' after repeated rejection; " + + "the original write is applied. ${rendered.content}", + tokenEstimate = estimateTokens(rendered.content), + role = EntryRole.TOOL, + ), + ) +} + /** * A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's * scope — block the task so the loop advances instead of the implementer retrying the same diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt index 262dc8b9..1e14e253 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt @@ -4,7 +4,9 @@ import com.correx.core.events.events.ToolCallAssessedEvent import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.OutsidePathAccessGrantedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.WriteScopeGrantedEvent import com.correx.core.events.risk.RiskAction import com.correx.core.toolintent.SessionContextProjection import com.correx.core.events.events.TransitionExecutedEvent @@ -68,6 +70,55 @@ internal fun SessionOrchestrator.grantedOutsidePaths(sessionId: SessionId): Set< .map { it.path } .toSet() +/** + * Write-scope/manifest paths the operator has approved widening into this session (folded from + * [WriteScopeGrantedEvent] — see #301). Replay-safe: reads the log, never re-prompts a path once + * granted. + */ + +internal fun SessionOrchestrator.escalatedWriteScopePaths(sessionId: SessionId): Set = + eventStore.read(sessionId) + .mapNotNull { it.payload as? WriteScopeGrantedEvent } + .filter { it.sessionId == sessionId } + .map { it.path } + .toSet() + +/** The rule codes whose BLOCK rationale (`"[CODE] message"`, see [toRiskSummary]) makes a + * rejection eligible for the #301 same-path escalation — a write rejected purely for being + * outside the claimed task's scope or the stage's manifest, not for any other reason (path + * traversal, privileged location, etc. are never escalated). */ +private val SCOPE_BLOCK_CODES = setOf("WRITE_SCOPE", "PATH_OUTSIDE_MANIFEST") + +internal fun SessionOrchestrator.isScopeBlockRationale(rationale: String): Boolean = + SCOPE_BLOCK_CODES.any { rationale.contains("[$it]") } + +/** + * Every (first-attempt-invocation, rejection) pair this session where [path] was rejected for a + * WRITE_SCOPE/PATH_OUTSIDE_MANIFEST reason, oldest first. Reached back into purely by folding the + * event log — no branching/replay-engine change needed (#301). The invocation carries the + * pristine [ToolRequest] parameters from that attempt, which may differ from the model's current + * (possibly degraded) call. + */ + +internal fun SessionOrchestrator.priorScopeRejections( + sessionId: SessionId, + path: String, +): List> { + val events = eventStore.read(sessionId) + val invocationsById = events + .mapNotNull { it.payload as? ToolInvocationRequestedEvent } + .filter { it.sessionId == sessionId } + .associateBy { it.invocationId } + return events + .mapNotNull { it.payload as? ToolExecutionRejectedEvent } + .filter { it.sessionId == sessionId && isScopeBlockRationale(it.reason) } + .mapNotNull { rejected -> + val invocation = invocationsById[rejected.invocationId] ?: return@mapNotNull null + val invocationPath = invocation.request.parameters["path"] as? String + if (invocationPath == path) invocation to rejected else null + } +} + /** * Returns true when the agent must be offered only read-only tools on the next inference turn. * Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it. diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt index 1299e954..108c42fb 100644 --- a/testing/integration/src/test/kotlin/ToolCallGateTest.kt +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -18,6 +18,8 @@ import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolRequest +import com.correx.core.events.events.WriteScopeGrantedEvent +import com.correx.core.toolintent.rules.ManifestContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReferenceExistsRule import com.correx.core.tools.contract.ParamRole @@ -50,6 +52,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationProjector import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestrationTuning import com.correx.core.kernel.orchestration.OrchestratorRepositories import com.correx.core.kernel.retry.DefaultRetryCoordinator import com.correx.core.risk.DefaultRiskAssessor @@ -717,6 +720,178 @@ class ToolCallGateTest { ) } + /** + * #301: a write repeatedly rejected for being outside the stage's declared manifest escalates + * to user approval after N same-path rejections instead of hard-blocking forever. On approval + * the FIRST attempt's pristine write is applied (widening the manifest for the session), and + * later attempts to the same path skip the prompt entirely. + */ + @Test + fun `Nth same-path PATH_OUTSIDE_MANIFEST rejection escalates to approval, which then executes the first attempt (#301)`(): Unit = + runBlocking { + val blockedPath = "/work/scratch/blocked.kt" + // T2 so the approval engine cannot auto-approve under PROMPT mode (T1 would auto-approve + // and the escalation would never actually pause) — mirrors the other PROMPT_USER tests. + val fileWriteTool = object : Tool { + override val name = "file_write" + override val description = "write" + override val parametersSchema: JsonObject = buildJsonObject {} + override val tier = Tier.T2 + override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) + override val paramRoles: Map = mapOf("path" to ParamRole.PATH) + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + } + val toolRegistry = object : ToolRegistry { + override fun resolve(name: String): Tool? = if (name == "file_write") fileWriteTool else null + override fun all(): List = listOf(fileWriteTool) + } + + var turnCount = 0 + val provider = object : InferenceProvider { + override val id = ProviderId("scope-escalate") + override val name = "scope-escalate" + override val tokenizer: Tokenizer = MockTokenizer() + val requests = mutableListOf() + override suspend fun infer(request: InferenceRequest): InferenceResponse { + requests += request + turnCount++ + // Turns 1-3 keep retrying the exact same out-of-manifest write; turn 4 (after + // the escalated approval resolves) stops so the run completes. + return if (turnCount <= 3) { + InferenceResponse( + request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0, + listOf( + ToolCallRequest( + id = "tc-$turnCount", + function = ToolCallFunction("file_write", """{"path":"$blockedPath"}"""), + ), + ), + ) + } else { + InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0) + } + } + override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy + override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) + } + + val fakeProbe = object : com.correx.core.toolintent.WorldProbe { + override fun exists(path: Path): Boolean = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + val eventStore = InMemoryEventStore() + val artifactStore = NoopArtifactStore() + val assessor = ToolCallAssessor(listOf(ManifestContainmentRule())) + val policy = WorkspacePolicy(workspace) + val executor = RecordingExecutor() + + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ), + ) + val engines = OrchestratorEngines( + transitionResolver = DefaultTransitionResolver { _, _ -> true }, + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = object : InferenceRouter { + override suspend fun route(stageId: StageId, requiredCapabilities: Set): InferenceProvider = provider + }, + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + toolExecutor = executor, + toolRegistry = toolRegistry, + toolCallAssessor = assessor, + workspacePolicy = policy, + worldProbe = fakeProbe, + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = artifactStore, + decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ), + // Escalate after 2 same-path rejections instead of the default 3, so the test's + // 3rd attempt is the one that triggers the approval prompt. + tuning = OrchestrationTuning(escalateScopeAfterN = 2), + ) + + val sessionId = SessionId("scope-escalate") + val graph = WorkflowGraph( + id = "scope-escalate-test", + stages = mapOf( + StageId("A") to StageConfig( + allowedTools = setOf("file_write"), + writeManifest = listOf("allowed/**"), + ), + ), + transitions = setOf( + TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }), + ), + start = StageId("A"), + ) + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + + val job = launch { orchestrator.run(sessionId, graph, config) } + + // Wait for the 3rd attempt's escalation to raise an ApprovalRequestedEvent. + val approval = withTimeout(5_000) { + var req: ApprovalRequestedEvent? = null + while (req == null) { + req = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent } + if (req == null) yield() + } + req + } + assertEquals("file_write", approval.toolName) + + // Exactly 2 hard rejections happened before the escalation (turns 1 and 2); the 3rd + // attempt paused for approval instead of rejecting again. + val rejectionsBeforeApproval = eventStore.read(sessionId).count { it.payload is ToolExecutionRejectedEvent } + assertEquals(2, rejectionsBeforeApproval, "expected exactly 2 hard rejections before escalation") + assertTrue(!executor.executeCalled.get(), "executor must not run before the escalated approval resolves") + + orchestrator.submitApprovalDecision( + approval.requestId, + ApprovalDecision( + id = null, + requestId = approval.requestId, + outcome = ApprovalOutcome.APPROVED, + state = ApprovalStatus.COMPLETED, + tier = Tier.T2, + contextSnapshot = ApprovalContext( + identity = ApprovalScopeIdentity(sessionId, StageId("A"), projectId = null), + mode = ApprovalMode.PROMPT, + ), + resolutionTimestamp = Clock.System.now(), + reason = "approved widening", + ), + ) + + withTimeout(5_000) { + while (eventStore.read(sessionId).none { it.payload is WriteScopeGrantedEvent }) yield() + } + job.join() + + assertTrue(executor.executeCalled.get(), "the approved write must execute") + val grant = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? WriteScopeGrantedEvent } + assertEquals(blockedPath, grant?.path) + } + @Test fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking { val executor = RecordingExecutor()