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:
2026-06-24 17:34:24 +00:00
parent 215a951b52
commit 5d48c26ec8
7 changed files with 156 additions and 8 deletions
@@ -61,6 +61,8 @@ data class CreateTaskRequest(
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
// Skip the duplicate-title guard and create anyway.
val force: Boolean = false,
)
// Null fields are left untouched; a present list (even empty) replaces the stored one.
@@ -151,8 +153,14 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
}
post {
val body = call.receive<CreateTaskRequest>()
val project = ProjectId(body.project)
// Duplicate-title guard (skippable with force): 409 with the look-alikes so the caller reuses one.
val duplicates = if (body.force) emptyList() else service.findDuplicates(project, body.title)
if (duplicates.isNotEmpty()) {
return@post call.respond(HttpStatusCode.Conflict, duplicates.map { it.toResponse() })
}
val task = service.createTask(
projectId = ProjectId(body.project),
projectId = project,
title = body.title,
goal = body.goal,
acceptanceCriteria = body.acceptanceCriteria,
@@ -307,6 +307,29 @@ class TaskRoutesTest {
}
}
@Test
fun `POST rejects a duplicate title with 409 and force overrides`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1 "JWT refresh"
val dup = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g"}""")
}
assertEquals(HttpStatusCode.Conflict, dup.status)
val rows = testJson.parseToJsonElement(dup.bodyAsText()) as JsonArray
assertEquals("demo-1", (rows.single() as JsonObject)["id"]!!.jsonPrimitive.content)
val forced = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g","force":true}""")
}
assertEquals(HttpStatusCode.Created, forced.status)
}
}
@Test
fun `bad requests are rejected`(@TempDir tempDir: Path) {
testApplication {