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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 18:43:17 +00:00
parent 146f96a6fd
commit 78e7a4fefb
2 changed files with 28 additions and 5 deletions
@@ -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 ""
@@ -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