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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user