diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/DefaultTaskClaimCoordinator.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/DefaultTaskClaimCoordinator.kt index 58e001de..e5616861 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/DefaultTaskClaimCoordinator.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/DefaultTaskClaimCoordinator.kt @@ -26,4 +26,8 @@ class DefaultTaskClaimCoordinator( override fun activeScope(sessionId: SessionId): List = taskService.activeClaim(sessionId.value)?.state?.affectedPaths ?: emptyList() + + override suspend fun blockActiveTask(sessionId: SessionId, reason: String) { + taskService.activeClaim(sessionId.value)?.let { taskService.block(it.taskId, reason) } + } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 71f8f4ec..dd49f549 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -154,6 +154,7 @@ import kotlin.coroutines.cancellation.CancellationException private const val MAX_TOOL_ROUNDS = 10 private const val STAGE_COMPLETE_TOOL = "stage_complete" +private const val SCOPE_PROPOSAL_TOOL = "propose_scope" private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE" private const val OUTPUT_SUMMARY_LIMIT = 500 private const val REPO_MAP_INJECT_TOP_K = 30 @@ -812,6 +813,7 @@ abstract class SessionOrchestrator( emitDecisionResolved(sessionId, domainRequest, engineDecision) if (!engineDecision.isApproved) { val rejectReason = engineDecision.reason ?: "denied" + blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason) emit(sessionId, ToolExecutionRejectedEvent( invocationId = invocationId, sessionId = sessionId, @@ -869,6 +871,7 @@ abstract class SessionOrchestrator( emitDecisionResolved(sessionId, domainRequest, userDecision) if (!userDecision.isApproved) { val rejectReason = userDecision.reason ?: "approval denied" + blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason) emit(sessionId, ToolExecutionRejectedEvent( invocationId = invocationId, sessionId = sessionId, @@ -992,6 +995,17 @@ abstract class SessionOrchestrator( } } + /** + * 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 + * out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected. + */ + private suspend fun blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) { + if (toolName == SCOPE_PROPOSAL_TOOL) { + taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason") + } + } + private suspend fun runPlane2Assessment( sessionId: SessionId, stageId: StageId, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TaskClaimCoordinator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TaskClaimCoordinator.kt index 2308d565..2be5f8a2 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TaskClaimCoordinator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TaskClaimCoordinator.kt @@ -19,4 +19,12 @@ interface TaskClaimCoordinator { /** Write-scope (affected paths) of the task the session currently has claimed; empty when none. */ fun activeScope(sessionId: SessionId): List + + /** + * Block the session's currently-claimed task — used when the operator rejects a scope-amendment + * proposal: the task can't proceed within scope, so it leaves the active/ready set and the loop + * advances to the next task instead of the implementer retrying the blocked write. No-op when + * nothing is claimed. + */ + suspend fun blockActiveTask(sessionId: SessionId, reason: String) } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ProposeScopeTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ProposeScopeTool.kt new file mode 100644 index 00000000..5cb3c535 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ProposeScopeTool.kt @@ -0,0 +1,86 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: when a write is blocked because the path is outside the claimed task's + * affected_paths, propose widening the scope. This is the recalibration valve for the execution + * loop — the implementer cannot grant itself more room (invariant #4): the tool is [Tier.T2], so it + * only executes after the operator approves. On approval it unions the proposed paths into the + * task's affected_paths ([TaskService.setAffectedPaths] → event); [TaskClaimCoordinator.activeScope] + * re-derives the wider manifest from events on the next tool call, so the same write then passes. + * On rejection the kernel blocks the task and the loop advances (see SessionOrchestrator). + * + * Operates on the session's currently-claimed task, so only the paths to add are needed. + */ +class ProposeScopeTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "propose_scope" + override val description: String = + "Propose widening the current task's affected_paths when a write is blocked for being out " + + "of scope. The operator must approve. On approval the paths are added and the blocked " + + "write can proceed; on rejection the task is blocked. Give the paths to add and why." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Path globs to add to the task's affected_paths.") + } + putJsonObject("reason") { + put("type", "string") + put("description", "Why the wider scope is needed — recorded on the task.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("paths")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult = + if (request.listParam("paths").isEmpty()) { + ValidationResult.Invalid("Missing 'paths' (non-empty string array).") + } else { + ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val task = service.activeClaim(request.sessionId.value) + ?: return fail(request, "No claimed task for this session — nothing to widen scope for.") + val add = request.listParam("paths") + if (add.isEmpty()) return fail(request, "Missing 'paths' (non-empty string array).") + + val widened = (task.state.affectedPaths + add).distinct() + service.setAffectedPaths(task.taskId, widened) + val reason = request.stringParam("reason") + service.addNote( + task.taskId, + TaskNoteAuthor.AGENT, + "[scope] widened +$add${reason?.let { ": $it" }.orEmpty()}", + ) + return ToolResult.Success( + invocationId = request.invocationId, + output = "Scope of ${task.taskId.value} widened to $widened — retry the blocked write.", + metadata = mapOf("taskId" to task.taskId.value), + ) + } + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index 150490e3..c008dbc4 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -28,6 +28,7 @@ object TaskTools { TaskCreateTool(service), TaskDecomposeTool(service), TaskUpdateTool(service, sessionFacts, sessionWrites), + ProposeScopeTool(service), TaskDeleteTool(service), TaskSearchTool(service), TaskReadyTool(service), diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 49a38b99..857b51ea 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -402,6 +402,29 @@ class TaskToolsTest { // Deleting again now fails: it is gone from active views. assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure) } + + @Test + fun `propose_scope unions new paths into the claimed task and keeps existing`() = runBlocking { + val id = createTask() + update.execute(request("task_update", mapOf("id" to id, "affected_paths" to listOf("src/a.kt")))) + update.execute(request("task_update", mapOf("id" to id, "action" to "claim"))) + + val result = ProposeScopeTool(service).execute( + request("propose_scope", mapOf("paths" to listOf("src/b.kt"), "reason" to "shared helper")), + ) + + assertTrue(result is ToolResult.Success) + assertEquals(listOf("src/a.kt", "src/b.kt"), service.getTask(TaskId(id))?.state?.affectedPaths) + } + + @Test + fun `propose_scope fails when the session has no claimed task`() = runBlocking { + createTask() // exists but unclaimed + val result = ProposeScopeTool(service).execute( + request("propose_scope", mapOf("paths" to listOf("src/b.kt"))), + ) + assertTrue(result is ToolResult.Failure) + } } /** Minimal append-capable in-memory store for tool tests. */