From 5d48c26ec8c599f4a8b4d36fd4f0a2304f662c92 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 17:34:24 +0000 Subject: [PATCH] feat(tasks): duplicate-title guard on task creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../correx/apps/cli/commands/TaskCommand.kt | 15 ++++-- .../correx/apps/server/routes/TaskRoutes.kt | 10 +++- .../apps/server/routes/TaskRoutesTest.kt | 23 +++++++++ .../com/correx/core/tasks/TaskService.kt | 22 +++++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 18 +++++++ .../tools/task/TaskCreateTool.kt | 29 ++++++++++-- .../tools/task/TaskToolsTest.kt | 47 +++++++++++++++++++ 7 files changed, 156 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index a29521ce..70bfce46 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -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(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(raw).id}") + } } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 81a0d994..34bda54d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -61,6 +61,8 @@ data class CreateTaskRequest( val goal: String, val acceptanceCriteria: List = emptyList(), val affectedPaths: List = 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() + 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, diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index a56f713e..9ec3c1c0 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -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 { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 2f83006b..ad31a48c 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -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 { + 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+") + } } diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index f9c19936..fea1f8ec 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -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 diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index 357860d3..5792dd0b 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -51,6 +51,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { putJsonObject("items") { put("type", "string") } 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( "required", @@ -72,10 +76,8 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { } override suspend fun execute(request: ToolRequest): ToolResult { - val invalid = validateRequest(request) as? ValidationResult.Invalid - if (invalid != null) { - return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) - } + val precheckFailure = precheck(request) + if (precheckFailure != null) return precheckFailure val task = service.createTask( projectId = ProjectId(request.stringParam("project")!!), 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), ) } + + /** 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, + ) + } + } } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 3c9707d3..e049dcdc 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -30,6 +30,7 @@ class TaskToolsTest { private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) private val search = TaskSearchTool(service) + private val ready = TaskReadyTool(service) private val context = TaskContextTool(TaskContextAssembler(service)) private var counter = 0 @@ -155,6 +156,52 @@ class TaskToolsTest { 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 fun `task_delete tombstones the task`() = runBlocking { val id = createTask()