From 78e7a4fefb44778638d7789701c301f101ed6dff Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 18:43:17 +0000 Subject: [PATCH] feat(tasks): dependency hard gate on claim Harden the claim warning into a block: task_update action=claim on a task with unmet blockers is now rejected (no mutation) with the blocker list, instead of claiming-with-a-warning. Escapable with force=true, which claims anyway and keeps the "claimed despite unmet blockers" warning for the audit trail. Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskUpdateTool.kt | 19 +++++++++++++++++-- .../tools/task/TaskToolsTest.kt | 14 +++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 147184b6..bc9a5677 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -69,6 +69,10 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.") } putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } + putJsonObject("force") { + put("type", "boolean") + put("description", "Claim even if the task has unmet blockers. Default false.") + } } put("required", buildJsonArray { add(JsonPrimitive("id")) }) } @@ -86,9 +90,11 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { val taskId = TaskId(id) if (service.getTask(taskId) == null) return fail(request, "No such task: $id") + val action = request.stringParam("action") + claimBlock(request, taskId, action)?.let { return it } + applyFieldEdits(request, taskId) - val action = request.stringParam("action") if (action != null && !applyAction(taskId, action, request)) { return fail(request, "Unknown action '$action'.") } @@ -113,7 +119,16 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { ) } - /** When a claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ + /** Refuse to claim a task with unmet blockers (escapable with force) — fail before any mutation. */ + private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { + if (action != "claim" || request.boolParam("force")) return null + val unmet = service.blockers(taskId) + if (unmet.isEmpty()) return null + val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } + return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") + } + + /** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ private fun claimWarning(taskId: TaskId): String { val blockers = service.blockers(taskId) if (blockers.isEmpty()) return "" 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 e049dcdc..20abf70a 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 @@ -188,7 +188,7 @@ class TaskToolsTest { } @Test - fun `task_update claim warns when blockers are unmet`() = runBlocking { + fun `task_update blocks claiming over unmet blockers, force overrides with a warning`() = runBlocking { create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1 create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2 update.execute( @@ -198,8 +198,16 @@ class TaskToolsTest { ), ) - val claimed = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim"))) - assertTrue((claimed as ToolResult.Success).output.contains("WARNING")) + // Plain claim is blocked — auth-1 is unfinished. + val blocked = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim"))) + assertTrue(blocked is ToolResult.Failure) + assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true)) + assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated + + // force=true claims anyway, with a warning surfaced. + val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + assertTrue((forced as ToolResult.Success).output.contains("WARNING")) + assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) } @Test