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:
@@ -6,6 +6,7 @@ import com.github.ajalt.clikt.core.CliktCommand
|
|||||||
import com.github.ajalt.clikt.core.subcommands
|
import com.github.ajalt.clikt.core.subcommands
|
||||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||||
import com.github.ajalt.clikt.parameters.options.default
|
import com.github.ajalt.clikt.parameters.options.default
|
||||||
|
import com.github.ajalt.clikt.parameters.options.flag
|
||||||
import com.github.ajalt.clikt.parameters.options.multiple
|
import com.github.ajalt.clikt.parameters.options.multiple
|
||||||
import com.github.ajalt.clikt.parameters.options.option
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
import com.github.ajalt.clikt.parameters.options.required
|
import com.github.ajalt.clikt.parameters.options.required
|
||||||
@@ -206,6 +207,7 @@ class TaskCreateCommand : TaskHttpCommand("create") {
|
|||||||
private val goal by option("--goal").required()
|
private val goal by option("--goal").required()
|
||||||
private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple()
|
private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple()
|
||||||
private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple()
|
private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple()
|
||||||
|
private val force by option("--force", help = "Create even if a same-title task exists").flag()
|
||||||
|
|
||||||
override fun run() = http { client ->
|
override fun run() = http { client ->
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
@@ -214,12 +216,19 @@ class TaskCreateCommand : TaskHttpCommand("create") {
|
|||||||
put("goal", goal)
|
put("goal", goal)
|
||||||
if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) }))
|
if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) }))
|
||||||
if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) }))
|
if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) }))
|
||||||
|
if (force) put("force", true)
|
||||||
}
|
}
|
||||||
val raw = client.post("${baseUrl()}/tasks") {
|
val resp = client.post("${baseUrl()}/tasks") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(payload)
|
setBody(payload)
|
||||||
}.bodyAsText()
|
}
|
||||||
if (jsonOut()) println(raw) else println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
|
val raw = resp.bodyAsText()
|
||||||
|
when {
|
||||||
|
resp.status == HttpStatusCode.Conflict ->
|
||||||
|
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}")
|
||||||
|
jsonOut() -> println(raw)
|
||||||
|
else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ data class CreateTaskRequest(
|
|||||||
val goal: String,
|
val goal: String,
|
||||||
val acceptanceCriteria: List<String> = emptyList(),
|
val acceptanceCriteria: List<String> = emptyList(),
|
||||||
val affectedPaths: 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.
|
// 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 {
|
post {
|
||||||
val body = call.receive<CreateTaskRequest>()
|
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(
|
val task = service.createTask(
|
||||||
projectId = ProjectId(body.project),
|
projectId = project,
|
||||||
title = body.title,
|
title = body.title,
|
||||||
goal = body.goal,
|
goal = body.goal,
|
||||||
acceptanceCriteria = body.acceptanceCriteria,
|
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
|
@Test
|
||||||
fun `bad requests are rejected`(@TempDir tempDir: Path) {
|
fun `bad requests are rejected`(@TempDir tempDir: Path) {
|
||||||
testApplication {
|
testApplication {
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ class TaskService(
|
|||||||
return TaskGraph.blocking(task, list(projectOf(taskId)))
|
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(
|
suspend fun createTask(
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
title: String,
|
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"))
|
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
|
@Test
|
||||||
fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking {
|
fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking {
|
||||||
val a = service.createTask(project, "A", "g").taskId // auth-1
|
val a = service.createTask(project, "A", "g").taskId // auth-1
|
||||||
|
|||||||
+25
-4
@@ -51,6 +51,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
|||||||
putJsonObject("items") { put("type", "string") }
|
putJsonObject("items") { put("type", "string") }
|
||||||
put("description", "Globs/paths this task is expected to touch.")
|
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(
|
put(
|
||||||
"required",
|
"required",
|
||||||
@@ -72,10 +76,8 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
val precheckFailure = precheck(request)
|
||||||
if (invalid != null) {
|
if (precheckFailure != null) return precheckFailure
|
||||||
return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
|
||||||
}
|
|
||||||
val task = service.createTask(
|
val task = service.createTask(
|
||||||
projectId = ProjectId(request.stringParam("project")!!),
|
projectId = ProjectId(request.stringParam("project")!!),
|
||||||
title = request.stringParam("title")!!,
|
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),
|
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 update = TaskUpdateTool(service)
|
||||||
private val delete = TaskDeleteTool(service)
|
private val delete = TaskDeleteTool(service)
|
||||||
private val search = TaskSearchTool(service)
|
private val search = TaskSearchTool(service)
|
||||||
|
private val ready = TaskReadyTool(service)
|
||||||
private val context = TaskContextTool(TaskContextAssembler(service))
|
private val context = TaskContextTool(TaskContextAssembler(service))
|
||||||
|
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
@@ -155,6 +156,52 @@ class TaskToolsTest {
|
|||||||
assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
|
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
|
@Test
|
||||||
fun `task_delete tombstones the task`() = runBlocking {
|
fun `task_delete tombstones the task`() = runBlocking {
|
||||||
val id = createTask()
|
val id = createTask()
|
||||||
|
|||||||
Reference in New Issue
Block a user