From 21e01da3ac4fe0f9643bc39e0b4e41da5bda8bb8 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 10:39:03 +0000 Subject: [PATCH] feat(tasks): task_decompose splits a goal into a dependency-linked graph in one approval The freestyle analyst can now break a large goal with dependency seams or independent review/handoff points into a parent epic + DEPENDS_ON-linked children in a single T2 approval, instead of N separate task_create calls. Parent DEPENDS_ON every child (completes last); each child IMPLEMENTS parent. Resolves depends_on by ref or index; rejects cycles, unresolved refs, missing title/goal, and empty batches; same batch dedup + force_reason convention as task_create. A session works one active task, so multi-task work is multi-session by construction: the analyst names the single ready task this run works, the architect threads only that one, and siblings are claimed by later runs via task_ready (claim-driven; no scheduler, /tasks/next stays rejected). Doctrine: analyst_freestyle.md picks one-task-vs-decompose and names the ready task; architect_freestyle.md threads only that one; plus the L0 policy line. freestyle_planning.toml analyst gains task_decompose (pinned by FreestylePlanningWorkflowTest). Co-Authored-By: Claude Opus 4.8 --- .correx/project.toml | 2 +- examples/workflows/freestyle_planning.toml | 9 +- .../workflows/prompts/analyst_freestyle.md | 18 +- .../workflows/prompts/architect_freestyle.md | 10 +- .../tools/task/TaskDecomposeTool.kt | 305 ++++++++++++++++++ .../infrastructure/tools/task/TaskTools.kt | 1 + .../tools/task/TaskToolsTest.kt | 111 +++++++ .../workflow/FreestylePlanningWorkflowTest.kt | 5 +- 8 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt diff --git a/.correx/project.toml b/.correx/project.toml index 3bf52623..cec04e25 100644 --- a/.correx/project.toml +++ b/.correx/project.toml @@ -5,7 +5,7 @@ conventions = [ "Dependency direction: apps -> core -> infrastructure; no cross-core imports", "No bare try-catch; use runCatching and sealed domain error types", "Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)", - "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now.", + "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now. When a goal has dependency seams or independent review points, task_decompose it into a parent + DEPENDS_ON-linked children (one approval) instead of one big task; a session works one task at a time, so siblings are claimed by later runs as they unblock.", ] [commands] diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index 3eec738e..a58457ce 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -2,14 +2,15 @@ id = "freestyle_planning" start = "analyst" # analyst writes no files, but it owns task framing: task_search/task_context (read-only) find -# existing work, and task_create (T2, approval-gated — a task is an event-log entry, not a file -# write) opens a task for this work and names its id in the analysis, so the architect can thread -# it into the plan's implementation stages. +# existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write) +# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with +# dependency seams into parent + DEPENDS_ON-linked children. Either way the analysis names the task +# id the run will work, so the architect threads it into the plan's implementation stages. [[stages]] id = "analyst" prompt = "prompts/analyst_freestyle.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"] token_budget = 16384 max_retries = 2 diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index f5fb363a..7772014c 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -4,10 +4,20 @@ given a directory path), `ls`, `grep`, `cat`, `find`. Before deriving requirements, check for existing work: `task_search` for related, duplicate, or blocking tasks and `task_context` to load any the goal names. Fold what you find into the -analysis rather than re-deriving it; flag a duplicate instead of restating it. If a task already -covers this work, name its id (e.g. `auth-142`) in the analysis; if none does and the work -warrants tracking (per the task policy), `task_create` one and name its id — either way later -stages thread it through the plan. +analysis rather than re-deriving it; flag a duplicate instead of restating it. + +Then frame the work as a task (per the task policy): +- If a task already covers this work, name its id (e.g. `auth-142`) in the analysis. +- If the goal is a single coherent unit one run can carry to review, `task_create` one and name its + id. +- If the goal has **dependency seams** (a thing that must land before another) or **independent + review/handoff points** (a piece worth shipping or reviewing on its own), `task_decompose` it into + a parent epic + `DEPENDS_ON`-linked children — one approval for the whole graph. A session works + one task at a time, so the children are claimed by *later* runs as they unblock; don't over-split. +- After decomposing, **name in the analysis the single task this run will work** — the one already + ready (no unmet dependency, e.g. the scaffold). Leave the blocked siblings for future runs. + +Either way later stages thread the named task through the plan; the rest wait to be claimed. Emit the `analysis` artifact (JSON, schema provided): - `summary`: the goal in your own words. diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index f97b2844..51790137 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -63,15 +63,17 @@ Emit a JSON object that validates against the `execution_plan` schema: (`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond this set. - **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that - the analyst found or opened with `task_create`; if none is referenced there is no task to - track). When one is referenced, thread it through the plan so the work stays tracked: + the analyst found, opened with `task_create`, or named as the ready task of a + `task_decompose` graph; if none is referenced there is no task to track). Thread **only that + one task** — this run works a single task; any sibling tasks the analyst decomposed are for + later runs to claim, so do not plan or reference them here. When one is referenced: - Give the stage that does the work `task_context` and `task_update`, and have its `prompt` `task_update action=claim` the task before starting and `action=submit_for_review` when its output is ready. - Give the final or review stage `task_context` and `task_update`, and have its `prompt` `task_update action=complete` the task once the work is accepted. - - If the `analysis` references no task, omit the task tools entirely. Do not create a - new task here — creation is out of scope for the plan. + - If the `analysis` references no task, omit the task tools entirely. Do not create or + decompose tasks here — task creation is out of scope for the plan. - Keep stages small and single-responsibility. Prefer more stages over large monolithic prompts. diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt new file mode 100644 index 00000000..0dae9869 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt @@ -0,0 +1,305 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.Task +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: break a large goal into a small task graph in ONE approval, instead of N + * separate [TaskCreateTool] calls. Use when the goal has dependency seams or independent + * review/handoff points (a thing that must land before another, or a piece worth reviewing on its + * own); for a single coherent unit you finish in one run, use task_create. Because a session works + * one task at a time, each node becomes a future claim — this tool decides the *shape*, claiming is + * still lazy (task_ready → claim), never scheduled. + * + * Creates the tasks and their `DEPENDS_ON` edges atomically (one dedup check, one approval) and + * returns the new ids plus which are ready to work now. An optional `parent` epic `DEPENDS_ON` every + * child (so it completes last) and each child `IMPLEMENTS` it (provenance). + * + * Nested args arrive flattened (the orchestrator stringifies non-primitive tool arguments), so the + * `tasks`/`parent` JSON is parsed here directly rather than via the list-param accessor. + */ +class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_decompose" + override val description: String = + "Break a large goal into a small task graph (parent epic + dependency-linked children) in " + + "one shot. Use when the goal has dependency seams or independent review/handoff points; " + + "for a single coherent unit you finish now, use task_create. Creates the tasks and their " + + "DEPENDS_ON links in a single approval; returns the new ids and which are ready to work now." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Project key shared by every task, e.g. 'webui'. Ids become '-'.") + } + putJsonObject("parent") { + put("type", "object") + put( + "description", + "Optional umbrella/epic. It DEPENDS_ON every child (completes last); each child IMPLEMENTS it.", + ) + putJsonObject("properties") { unitProperties() } + } + putJsonObject("tasks") { + put("type", "array") + put("description", "The units of work, in order. Declare cross-task ordering with depends_on.") + putJsonObject("items") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("ref") { + put("type", "string") + put("description", "Local handle other tasks cite in depends_on (e.g. 'scaffold').") + } + unitProperties() + putJsonObject("depends_on") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) }) + } + } + putJsonObject("force") { + put("type", "boolean") + put("description", "Create even if a title duplicates an active task. Default false.") + } + putJsonObject("force_reason") { + put("type", "string") + put("description", "Required when force=true: why duplicates are acceptable. Recorded on each task.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("project")); add(JsonPrimitive("tasks")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).") + if (elementOf(request, "tasks") !is JsonArray) { + return ValidationResult.Invalid("Missing 'tasks' (JSON array).") + } + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val project = request.stringParam("project") ?: return fail(request, "Missing 'project' (string).") + val specs = parseSpecs(elementOf(request, "tasks") as? JsonArray) + ?: return fail(request, "'tasks' must be a JSON array of {title, goal, ...}.") + if (specs.isEmpty()) return fail(request, "'tasks' must contain at least one task.") + specs.withIndex().firstOrNull { it.value.title.isBlank() || it.value.goal.isBlank() }?.let { + return fail(request, "task #${it.index} is missing 'title' or 'goal'.") + } + val parent = parseSpec(elementOf(request, "parent") as? JsonObject) + if (parent != null && (parent.title.isBlank() || parent.goal.isBlank())) { + return fail(request, "'parent' needs both 'title' and 'goal'.") + } + + val adjacency = when (val e = buildEdges(specs)) { + is Edges.Err -> return fail(request, e.message) + is Edges.Ok -> e.adjacency + } + dedupFailure(request, project, specs, parent)?.let { return it } + + val pid = ProjectId(project) + val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } + val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } + wireLinks(adjacency, children, parentTask) + recordProvenance(request, listOfNotNull(parentTask) + children) + + val createdIds = (children + listOfNotNull(parentTask)).joinToString(",") { it.taskId.value } + val readyIds = children.filter { service.blockers(it.taskId).isEmpty() }.map { it.taskId.value } + return ToolResult.Success( + invocationId = request.invocationId, + output = render(children, parentTask, readyIds), + metadata = mapOf("taskIds" to createdIds, "readyIds" to readyIds.joinToString(","), "project" to project), + ) + } + + /** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */ + private suspend fun wireLinks(adjacency: List>, children: List, parentTask: Task?) { + adjacency.forEachIndexed { i, deps -> + deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) } + } + if (parentTask != null) { + children.forEach { child -> + // Parent waits on every child; child records the epic it implements. + service.link(parentTask.taskId, child.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + service.link(child.taskId, parentTask.taskId.value, TaskLinkType.IMPLEMENTS, TaskTargetKind.TASK) + } + } + } + + private suspend fun recordProvenance(request: ToolRequest, created: List) { + created.forEach { service.linkOriginSession(it.taskId, request) } + if (!request.boolParam("force")) return + val reason = request.stringParam("force_reason") ?: return + created.forEach { + service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason") + } + } + + /** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */ + private fun dedupFailure(request: ToolRequest, project: String, specs: List, parent: TaskSpec?): ToolResult.Failure? { + val titles = (listOfNotNull(parent?.title) + specs.map { it.title }) + val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys + if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.") + if (request.boolParam("force")) { + return if (request.stringParam("force_reason") == null) { + fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") + } else { + null + } + } + val pid = ProjectId(project) + val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value } + return clashes.takeIf { it.isNotEmpty() }?.let { + val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } + fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") + } + } + + private fun render(children: List, parentTask: Task?, readyIds: List): String { + val lines = children.map { c -> + val unmet = service.blockers(c.taskId).map { it.taskId.value } + val state = if (unmet.isEmpty()) "READY" else "blocked by ${unmet.joinToString(", ")}" + "- ${c.taskId.value} '${c.state.title.orEmpty()}' [$state]" + } + listOfNotNull( + parentTask?.let { p -> + val unmet = service.blockers(p.taskId).map { it.taskId.value } + "- ${p.taskId.value} '${p.state.title.orEmpty()}' (epic) [blocked by ${unmet.joinToString(", ")}]" + }, + ) + val readyLine = if (readyIds.isEmpty()) { + "Nothing is ready yet — check the dependency graph." + } else { + "Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete." + } + return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" + + lines.joinToString("\n") + "\n" + readyLine + } + + // --- parsing (flattened JSON args) --- + + private data class TaskSpec( + val ref: String?, + val title: String, + val goal: String, + val acceptanceCriteria: List, + val affectedPaths: List, + val deps: List, + ) + + private fun parseSpecs(element: JsonArray?): List? = + element?.map { parseSpec(it as? JsonObject) ?: return null } + + private fun parseSpec(obj: JsonObject?): TaskSpec? { + if (obj == null) return null + return TaskSpec( + ref = str(obj, "ref"), + title = str(obj, "title").orEmpty(), + goal = str(obj, "goal").orEmpty(), + acceptanceCriteria = strList(obj, "acceptance_criteria"), + affectedPaths = strList(obj, "affected_paths"), + deps = strList(obj, "depends_on"), + ) + } + + private fun str(obj: JsonObject, key: String): String? = + (obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" } + + private fun strList(obj: JsonObject, key: String): List = + (obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList() + + /** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */ + private fun elementOf(request: ToolRequest, name: String) = + request.parameters[name]?.let { runCatching { Json.parseToJsonElement(it.toString()) }.getOrNull() } + + private fun normalize(title: String): String = title.trim().lowercase().replace(Regex("\\s+"), " ") + + // --- dependency edges + cycle check --- + + private sealed interface Edges { + data class Ok(val adjacency: List>) : Edges + data class Err(val message: String) : Edges + } + + private fun buildEdges(specs: List): Edges { + val refToIndex = HashMap() + specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } } + val adjacency = ArrayList>() + specs.forEachIndexed { i, s -> + val deps = ArrayList() + for (d in s.deps) { + val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices } + ?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.") + if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.") + deps.add(j) + } + adjacency.add(deps.distinct()) + } + cyclePath(adjacency)?.let { path -> + return Edges.Err("Dependency cycle: ${path.joinToString(" -> ") { specs[it].title }}.") + } + return Edges.Ok(adjacency) + } + + /** Returns a back-edge cycle as a list of task indices, or null when the graph is acyclic. */ + private fun cyclePath(adjacency: List>): List? { + val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done + val stack = ArrayList() + fun dfs(u: Int): List? { + color[u] = 1 + stack.add(u) + for (v in adjacency[u]) { + if (color[v] == 1) return stack.subList(stack.indexOf(v), stack.size).toList() + v + if (color[v] == 0) dfs(v)?.let { return it } + } + color[u] = 2 + stack.removeAt(stack.lastIndex) + return null + } + for (i in adjacency.indices) if (color[i] == 0) dfs(i)?.let { return it } + return null + } + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) +} + +/** The fields a task spec and the parent epic share, declared once for the schema. */ +private fun kotlinx.serialization.json.JsonObjectBuilder.unitProperties() { + putJsonObject("title") { put("type", "string"); put("description", "Short imperative title.") } + putJsonObject("goal") { put("type", "string"); put("description", "What 'done' looks like.") } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Concrete, verifiable criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Globs/paths this task is expected to touch.") + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index e3103af5..150490e3 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -26,6 +26,7 @@ object TaskTools { ): List = listOf( TaskCreateTool(service), + TaskDecomposeTool(service), TaskUpdateTool(service, sessionFacts, sessionWrites), TaskDeleteTool(service), TaskSearchTool(service), 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 dd614d72..49a38b99 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 @@ -27,6 +27,7 @@ class TaskToolsTest { private val service = TaskService(InMemoryEventStore()) private val create = TaskCreateTool(service) + private val decompose = TaskDecomposeTool(service) private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) private val search = TaskSearchTool(service) @@ -63,6 +64,116 @@ class TaskToolsTest { assertTrue(result is ToolResult.Failure) } + // task_decompose receives nested args as JSON strings (the orchestrator flattens non-primitive + // tool arguments), so the tests pass strings to exercise the real parse path — not structured Lists. + private val threeTaskGraph = """ + [ + {"ref":"scaffold","title":"Scaffold app","goal":"shell + build","affected_paths":["apps/web/**"]}, + {"ref":"auth","title":"Auth view","goal":"login","depends_on":["scaffold"]}, + {"ref":"dash","title":"Dashboard","goal":"home","depends_on":["scaffold"]} + ] + """.trimIndent() + + @Test + fun `task_decompose builds a DEPENDS_ON graph with one ready root and a blocked epic`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "parent" to """{"title":"Frontend web UI","goal":"web ui for correx"}""", + "tasks" to threeTaskGraph, + ), + ), + ) + assertTrue(result is ToolResult.Success) + val meta = (result as ToolResult.Success).metadata + // parent created first (webui-1), then children in order (webui-2..4). + assertEquals("webui-2,webui-3,webui-4,webui-1", meta.getValue("taskIds")) + assertEquals("webui-2", meta.getValue("readyIds")) + // affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg). + assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths) + // scaffold is the only ready task; the dependents and the epic are blocked. + assertEquals(listOf("webui-2"), service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value }) + assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value }) + assertEquals(3, service.blockers(TaskId("webui-1")).size) + // child implements the epic; epic depends on the child. + assertTrue( + service.getTask(TaskId("webui-2"))!!.state.links.any { + it.type == TaskLinkType.IMPLEMENTS && it.targetKind == TaskTargetKind.TASK && it.targetId == "webui-1" + }, + ) + } + + @Test + fun `task_decompose resolves depends_on by index`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "tasks" to """[{"title":"Scaffold","goal":"g"},{"title":"Wire","goal":"g","depends_on":["0"]}]""", + ), + ), + ) + assertTrue(result is ToolResult.Success) + assertEquals(listOf("webui-1"), service.blockers(TaskId("webui-2")).map { it.taskId.value }) + } + + @Test + fun `task_decompose rejects a dependency cycle`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "tasks" to """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""", + ), + ), + ) + assertTrue(result is ToolResult.Failure) + assertTrue((result as ToolResult.Failure).reason.contains("cycle", ignoreCase = true)) + } + + @Test + fun `task_decompose rejects an unresolved dependency reference`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf("project" to "webui", "tasks" to """[{"title":"A","goal":"g","depends_on":["ghost"]}]"""), + ), + ) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose rejects a task missing title or goal`() = runBlocking { + val result = decompose.execute( + request("task_decompose", mapOf("project" to "webui", "tasks" to """[{"title":"A"}]""")), + ) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose rejects an empty task list`() = runBlocking { + val result = decompose.execute(request("task_decompose", mapOf("project" to "webui", "tasks" to "[]"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose blocks a duplicate title and force needs a recorded reason`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "webui", "title" to "Scaffold app", "goal" to "g"))) + val dup = mapOf("project" to "webui", "tasks" to """[{"title":"Scaffold app","goal":"again"}]""") + + assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure) + assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure) + + val forced = decompose.execute(request("task_decompose", dup + ("force" to true) + ("force_reason" to "intentional rebuild"))) + assertTrue(forced is ToolResult.Success) + val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",") + assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") }) + } + @Test fun `task_update applies status action, edits, link and note`() = runBlocking { val id = createTask() diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt index 4dd07652..2fe406d9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt @@ -70,9 +70,10 @@ class FreestylePlanningWorkflowTest { assertEquals(2, graph.transitions.size) - // analyst frames the work: search + open a task (the architect threads it into the plan). + // analyst frames the work: search + open one task or decompose into a graph (the architect + // threads the named task into the plan). assertEquals( - setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"), + setOf("file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"), graph.stages[StageId("analyst")]!!.allowedTools, ) }