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
@@ -104,6 +104,23 @@ class TaskService(
return TaskGraph.blocking(task, list(projectOf(taskId)))
}
/**
* Active (non-terminal) tasks in [projectId] whose title matches [title] after normalization
* (case- and whitespace-insensitive) — the backstop against an agent re-creating a task it
* should have found by search. A blank title never matches; a DONE/CANCELLED look-alike is not
* a duplicate (that work may legitimately recur).
*/
fun findDuplicates(projectId: ProjectId, title: String): List<Task> {
val needle = normalizeTitle(title)
if (needle.isEmpty()) return emptyList()
return list(projectId).filter {
it.state.status !in TERMINAL_STATUSES && normalizeTitle(it.state.title.orEmpty()) == needle
}
}
private fun normalizeTitle(title: String): String =
title.trim().lowercase().replace(WHITESPACE, " ")
suspend fun createTask(
projectId: ProjectId,
title: String,
@@ -207,4 +224,9 @@ class TaskService(
),
)
}
private companion object {
val TERMINAL_STATUSES = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
val WHITESPACE = Regex("\\s+")
}
}
@@ -105,6 +105,24 @@ class TaskServiceTest {
assertTrue(lines.last().contains("completed"))
}
@Test
fun `findDuplicates matches active same-title tasks but ignores terminal ones`() = runBlocking {
service.createTask(project, "Add JWT refresh", "g") // auth-1, active
// Case- and whitespace-insensitive match.
assertEquals(
listOf("auth-1"),
service.findDuplicates(project, " add jwt REFRESH ").map { it.taskId.value },
)
// A DONE look-alike is not a duplicate — that work may legitimately recur.
val done = service.createTask(project, "Rotate keys", "g").taskId
service.claim(done, "x"); service.submitForReview(done); service.complete(done)
assertTrue(service.findDuplicates(project, "Rotate keys").isEmpty())
assertTrue(service.findDuplicates(project, "totally new").isEmpty())
}
@Test
fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking {
val a = service.createTask(project, "A", "g").taskId // auth-1