feat(tasks): duplicate-title guard on task creation
The doctrine says "search before creating", but that leans on the LLM; this is the backstop against an agent re-creating a task across runs. TaskService. findDuplicates matches active (non-terminal) same-title tasks, case- and whitespace-insensitively — a DONE/CANCELLED look-alike is not a duplicate, since that work may legitimately recur. - task_create rejects a duplicate, naming the look-alike and offering force=true. - POST /tasks → 409 with the duplicates, or force:true to override; correx task create --force. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+25
-4
@@ -51,6 +51,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Globs/paths this task is expected to touch.")
|
||||
}
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Create even if an active task with the same title exists. Default false.")
|
||||
}
|
||||
}
|
||||
put(
|
||||
"required",
|
||||
@@ -72,10 +76,8 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||
if (invalid != null) {
|
||||
return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||
}
|
||||
val precheckFailure = precheck(request)
|
||||
if (precheckFailure != null) return precheckFailure
|
||||
val task = service.createTask(
|
||||
projectId = ProjectId(request.stringParam("project")!!),
|
||||
title = request.stringParam("title")!!,
|
||||
@@ -92,4 +94,23 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name),
|
||||
)
|
||||
}
|
||||
|
||||
/** Required-field validation, then a duplicate-title guard (unless force) — null when OK to create. */
|
||||
private fun precheck(request: ToolRequest): ToolResult.Failure? {
|
||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||
val duplicates = if (request.boolParam("force")) {
|
||||
emptyList()
|
||||
} else {
|
||||
service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
}
|
||||
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -30,6 +30,7 @@ class TaskToolsTest {
|
||||
private val update = TaskUpdateTool(service)
|
||||
private val delete = TaskDeleteTool(service)
|
||||
private val search = TaskSearchTool(service)
|
||||
private val ready = TaskReadyTool(service)
|
||||
private val context = TaskContextTool(TaskContextAssembler(service))
|
||||
|
||||
private var counter = 0
|
||||
@@ -155,6 +156,52 @@ class TaskToolsTest {
|
||||
assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_ready lists unblocked TODO tasks only`() = 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
|
||||
// auth-2 depends on auth-1 (still TODO) → auth-2 not ready.
|
||||
update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
|
||||
),
|
||||
)
|
||||
|
||||
val out = (ready.execute(request("task_ready", emptyMap())) as ToolResult.Success).output
|
||||
assertTrue(out.contains("auth-1"))
|
||||
assertFalse(out.contains("auth-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create blocks a duplicate title and force overrides`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))
|
||||
|
||||
val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")))
|
||||
assertTrue(dup is ToolResult.Failure)
|
||||
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
|
||||
|
||||
val forced = create.execute(
|
||||
request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)),
|
||||
)
|
||||
assertTrue(forced is ToolResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update claim warns when blockers are unmet`() = 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(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
|
||||
),
|
||||
)
|
||||
|
||||
val claimed = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim")))
|
||||
assertTrue((claimed as ToolResult.Success).output.contains("WARNING"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_delete tombstones the task`() = runBlocking {
|
||||
val id = createTask()
|
||||
|
||||
Reference in New Issue
Block a user