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 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+305
@@ -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 '<project>-<n>'.")
|
||||
}
|
||||
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<ToolCapability> = 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<List<Int>>, children: List<Task>, 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<Task>) {
|
||||
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<TaskSpec>, 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<Task>, parentTask: Task?, readyIds: List<String>): 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<String>,
|
||||
val affectedPaths: List<String>,
|
||||
val deps: List<String>,
|
||||
)
|
||||
|
||||
private fun parseSpecs(element: JsonArray?): List<TaskSpec>? =
|
||||
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<String> =
|
||||
(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<List<Int>>) : Edges
|
||||
data class Err(val message: String) : Edges
|
||||
}
|
||||
|
||||
private fun buildEdges(specs: List<TaskSpec>): Edges {
|
||||
val refToIndex = HashMap<String, Int>()
|
||||
specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } }
|
||||
val adjacency = ArrayList<List<Int>>()
|
||||
specs.forEachIndexed { i, s ->
|
||||
val deps = ArrayList<Int>()
|
||||
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<Int>>): List<Int>? {
|
||||
val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done
|
||||
val stack = ArrayList<Int>()
|
||||
fun dfs(u: Int): List<Int>? {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ object TaskTools {
|
||||
): List<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskDecomposeTool(service),
|
||||
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
|
||||
+111
@@ -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()
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user