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.parameters.arguments.argument
|
||||
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.option
|
||||
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 criteria by option("--criteria", help = "Acceptance criterion (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 ->
|
||||
val payload = buildJsonObject {
|
||||
@@ -214,12 +216,19 @@ class TaskCreateCommand : TaskHttpCommand("create") {
|
||||
put("goal", goal)
|
||||
if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.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)
|
||||
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 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 {
|
||||
|
||||
Reference in New Issue
Block a user