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",
|
"Dependency direction: apps -> core -> infrastructure; no cross-core imports",
|
||||||
"No bare try-catch; use runCatching and sealed domain error types",
|
"No bare try-catch; use runCatching and sealed domain error types",
|
||||||
"Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)",
|
"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]
|
[commands]
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ id = "freestyle_planning"
|
|||||||
start = "analyst"
|
start = "analyst"
|
||||||
|
|
||||||
# analyst writes no files, but it owns task framing: task_search/task_context (read-only) find
|
# 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
|
# existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write)
|
||||||
# write) opens a task for this work and names its id in the analysis, so the architect can thread
|
# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with
|
||||||
# it into the plan's implementation stages.
|
# 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]]
|
[[stages]]
|
||||||
id = "analyst"
|
id = "analyst"
|
||||||
prompt = "prompts/analyst_freestyle.md"
|
prompt = "prompts/analyst_freestyle.md"
|
||||||
produces = [{ name = "analysis", kind = "analysis" }]
|
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
|
token_budget = 16384
|
||||||
max_retries = 2
|
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
|
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
|
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
|
analysis rather than re-deriving it; flag a duplicate instead of restating it.
|
||||||
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
|
Then frame the work as a task (per the task policy):
|
||||||
stages thread it through the plan.
|
- 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):
|
Emit the `analysis` artifact (JSON, schema provided):
|
||||||
- `summary`: the goal in your own words.
|
- `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
|
(`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond
|
||||||
this set.
|
this set.
|
||||||
- **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that
|
- **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
|
the analyst found, opened with `task_create`, or named as the ready task of a
|
||||||
track). When one is referenced, thread it through the plan so the work stays tracked:
|
`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
|
- 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
|
`prompt` `task_update action=claim` the task before starting and
|
||||||
`action=submit_for_review` when its output is ready.
|
`action=submit_for_review` when its output is ready.
|
||||||
- Give the final or review stage `task_context` and `task_update`, and have its `prompt`
|
- 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.
|
`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
|
- If the `analysis` references no task, omit the task tools entirely. Do not create or
|
||||||
new task here — creation is out of scope for the plan.
|
decompose tasks here — task creation is out of scope for the plan.
|
||||||
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
|
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
|
||||||
prompts.
|
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> =
|
): List<Tool> =
|
||||||
listOf(
|
listOf(
|
||||||
TaskCreateTool(service),
|
TaskCreateTool(service),
|
||||||
|
TaskDecomposeTool(service),
|
||||||
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||||
TaskDeleteTool(service),
|
TaskDeleteTool(service),
|
||||||
TaskSearchTool(service),
|
TaskSearchTool(service),
|
||||||
|
|||||||
+111
@@ -27,6 +27,7 @@ class TaskToolsTest {
|
|||||||
|
|
||||||
private val service = TaskService(InMemoryEventStore())
|
private val service = TaskService(InMemoryEventStore())
|
||||||
private val create = TaskCreateTool(service)
|
private val create = TaskCreateTool(service)
|
||||||
|
private val decompose = TaskDecomposeTool(service)
|
||||||
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)
|
||||||
@@ -63,6 +64,116 @@ class TaskToolsTest {
|
|||||||
assertTrue(result is ToolResult.Failure)
|
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
|
@Test
|
||||||
fun `task_update applies status action, edits, link and note`() = runBlocking {
|
fun `task_update applies status action, edits, link and note`() = runBlocking {
|
||||||
val id = createTask()
|
val id = createTask()
|
||||||
|
|||||||
+3
-2
@@ -70,9 +70,10 @@ class FreestylePlanningWorkflowTest {
|
|||||||
|
|
||||||
assertEquals(2, graph.transitions.size)
|
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(
|
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,
|
graph.stages[StageId("analyst")]!!.allowedTools,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user