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:
+17
-2
@@ -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.")
|
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("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")) })
|
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||||
}
|
}
|
||||||
@@ -86,9 +90,11 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
|
|||||||
val taskId = TaskId(id)
|
val taskId = TaskId(id)
|
||||||
if (service.getTask(taskId) == null) return fail(request, "No such task: $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)
|
applyFieldEdits(request, taskId)
|
||||||
|
|
||||||
val action = request.stringParam("action")
|
|
||||||
if (action != null && !applyAction(taskId, action, request)) {
|
if (action != null && !applyAction(taskId, action, request)) {
|
||||||
return fail(request, "Unknown action '$action'.")
|
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 {
|
private fun claimWarning(taskId: TaskId): String {
|
||||||
val blockers = service.blockers(taskId)
|
val blockers = service.blockers(taskId)
|
||||||
if (blockers.isEmpty()) return ""
|
if (blockers.isEmpty()) return ""
|
||||||
|
|||||||
+11
-3
@@ -188,7 +188,7 @@ class TaskToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 "A", "goal" to "g"))) // auth-1
|
||||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
|
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
|
||||||
update.execute(
|
update.execute(
|
||||||
@@ -198,8 +198,16 @@ class TaskToolsTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val claimed = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim")))
|
// Plain claim is blocked — auth-1 is unfinished.
|
||||||
assertTrue((claimed as ToolResult.Success).output.contains("WARNING"))
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user