feat(workflow): tasked execution loop replaces role_pipeline
role_pipeline.toml is now analyst → architect → decomposer → loop(claim → implement → review) → done. The decomposer (require_task_decompose) breaks the design into a task graph; the implementer stage claims the next ready task on entry (claim_task = true, kernel-driven) with writes auto-scoped to the task's affected_paths and propose_scope as the operator-approved widen valve. The loop is gated by the tasks_ready predicate. Loop precedence rides the resolver's by-id transition ordering (review-1/2/3) to avoid composite all_of/not conditions in the flat TOML schema: changes → implementer, approved+tasks_ready → implementer, else → done. TomlWorkflowLoader gains a claim_task stage field (→ claimTask metadata) and extracts the metadata build into StageSection.toMetadata. RolePipelineWorkflowTest rewritten for the tasked graph.
This commit is contained in:
+13
-9
@@ -50,6 +50,7 @@ private data class StageSection(
|
||||
@param:JsonProperty("ground_references") val groundReferences: Boolean = false,
|
||||
@param:JsonProperty("brief_echo") val briefEcho: Boolean = false,
|
||||
@param:JsonProperty("require_task_decompose") val requireTaskDecompose: Boolean = false,
|
||||
@param:JsonProperty("claim_task") val claimTask: Boolean = false,
|
||||
)
|
||||
|
||||
// condition fields flattened into the transition row
|
||||
@@ -119,15 +120,7 @@ class TomlWorkflowLoader(
|
||||
maxTokens = s.tokenBudget,
|
||||
),
|
||||
maxRetries = s.maxRetries,
|
||||
metadata = buildMap {
|
||||
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
|
||||
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
|
||||
if (s.requiresApproval) put("requiresApproval", "true")
|
||||
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
|
||||
if (s.groundReferences) put("groundReferences", "true")
|
||||
if (s.briefEcho) put("briefEcho", "true")
|
||||
if (s.requireTaskDecompose) put("requireTaskDecompose", "true")
|
||||
},
|
||||
metadata = s.toMetadata(workflowDir),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,6 +157,17 @@ class TomlWorkflowLoader(
|
||||
return WorkflowGraph(id = id, stages = stageMap, transitions = transitionSet, start = startId)
|
||||
}
|
||||
|
||||
private fun StageSection.toMetadata(workflowDir: Path?): Map<String, String> = buildMap {
|
||||
prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
|
||||
systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
|
||||
if (requiresApproval) put("requiresApproval", "true")
|
||||
if (injectArtifactKinds) put("injectArtifactKinds", "true")
|
||||
if (groundReferences) put("groundReferences", "true")
|
||||
if (briefEcho) put("briefEcho", "true")
|
||||
if (requireTaskDecompose) put("requireTaskDecompose", "true")
|
||||
if (claimTask) put("claimTask", "true")
|
||||
}
|
||||
|
||||
@Suppress("ThrowsCount")
|
||||
private fun validate(
|
||||
start: String,
|
||||
|
||||
+49
-32
@@ -7,6 +7,7 @@ import com.correx.core.artifacts.kind.JsonSchemaProperty
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.conditions.ArtifactFieldEquals
|
||||
import com.correx.core.transitions.conditions.FieldOperator
|
||||
import com.correx.core.transitions.conditions.TasksReady
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Path
|
||||
@@ -29,8 +30,6 @@ class RolePipelineWorkflowTest {
|
||||
private val registry = DefaultArtifactKindRegistry().apply {
|
||||
register(llmKind("analysis"))
|
||||
register(llmKind("design"))
|
||||
register(llmKind("brief_echo"))
|
||||
register(llmKind("impl_plan"))
|
||||
register(llmKind("review_report"))
|
||||
}
|
||||
|
||||
@@ -46,49 +45,67 @@ class RolePipelineWorkflowTest {
|
||||
return null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shipped role_pipeline toml parses with the full role graph`() {
|
||||
val path = repoFile("examples/workflows/role_pipeline.toml")
|
||||
assumeTrue(path != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
|
||||
private fun loadGraph() =
|
||||
repoFile("examples/workflows/role_pipeline.toml")?.let { TomlWorkflowLoader(registry).load(it) }
|
||||
|
||||
val graph = TomlWorkflowLoader(registry).load(path!!)
|
||||
@Test
|
||||
fun `shipped role_pipeline toml parses with the tasked execution graph`() {
|
||||
val graph = loadGraph()
|
||||
assumeTrue(graph != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
|
||||
graph!!
|
||||
|
||||
assertEquals(
|
||||
setOf("analyst", "architect", "brief_echo", "planner", "implementer", "reviewer"),
|
||||
setOf("analyst", "architect", "decomposer", "implementer", "reviewer"),
|
||||
graph.stages.keys.map { it.value }.toSet(),
|
||||
)
|
||||
assertEquals(7, graph.transitions.size)
|
||||
|
||||
// Forward chaining via needs.
|
||||
assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan")))
|
||||
// The decomposer is the mandatory task-graph gate.
|
||||
assertEquals("true", graph.stages[StageId("decomposer")]!!.metadata["requireTaskDecompose"])
|
||||
// The implementer claims a task on entry.
|
||||
assertEquals("true", graph.stages[StageId("implementer")]!!.metadata["claimTask"])
|
||||
}
|
||||
|
||||
// Verdict-gated loop: approved → done, changes → implementer.
|
||||
val approved = graph.transitions.first { it.to == StageId("done") }.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.EQ, approved.operator)
|
||||
assertEquals("verdict", approved.field)
|
||||
val loop = graph.transitions
|
||||
.first { it.from == StageId("reviewer") && it.to == StageId("implementer") }
|
||||
.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.NEQ, loop.operator)
|
||||
@Test
|
||||
fun `loop is gated by verdict and the task board`() {
|
||||
val graph = loadGraph()
|
||||
assumeTrue(graph != null, "role_pipeline.toml not found")
|
||||
graph!!
|
||||
|
||||
// Decomposer only advances once tasks exist.
|
||||
val toImpl = graph.transitions
|
||||
.first { it.from == StageId("decomposer") && it.to == StageId("implementer") }
|
||||
assertEquals(TasksReady, toImpl.condition)
|
||||
|
||||
// Reviewer edges, in id (= evaluation) order: changes → impl, tasks_ready → impl, else → done.
|
||||
val reviewerEdges = graph.transitions
|
||||
.filter { it.from == StageId("reviewer") }
|
||||
.sortedBy { it.id.value }
|
||||
assertEquals(
|
||||
listOf("review-1-changes", "review-2-more", "review-3-done"),
|
||||
reviewerEdges.map { it.id.value },
|
||||
)
|
||||
// 1. changes requested (verdict != approved) loops back to the implementer.
|
||||
val changes = reviewerEdges[0]
|
||||
assertEquals(StageId("implementer"), changes.to)
|
||||
assertEquals(FieldOperator.NEQ, (changes.condition as ArtifactFieldEquals).operator)
|
||||
// 2. approved + tasks remain → claim the next.
|
||||
assertEquals(StageId("implementer"), reviewerEdges[1].to)
|
||||
assertEquals(TasksReady, reviewerEdges[1].condition)
|
||||
// 3. approved + board drained → done (fallthrough).
|
||||
assertEquals(StageId("done"), reviewerEdges[2].to)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task tools are granted to the right stages`() {
|
||||
val path = repoFile("examples/workflows/role_pipeline.toml")
|
||||
assumeTrue(path != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
|
||||
val graph = TomlWorkflowLoader(registry).load(path!!)
|
||||
val graph = loadGraph()
|
||||
assumeTrue(graph != null, "role_pipeline.toml not found")
|
||||
graph!!
|
||||
|
||||
// analyst opens + searches (read-only on files); creation is approval-gated (T2).
|
||||
assertEquals(
|
||||
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"),
|
||||
graph.stages[StageId("analyst")]!!.allowedTools,
|
||||
)
|
||||
// implementer owns the lifecycle: claim/submit + the full file set.
|
||||
assertEquals(
|
||||
setOf("file_read", "file_write", "file_edit", "ShellTool") +
|
||||
setOf("task_create", "task_update", "task_context", "task_search"),
|
||||
graph.stages[StageId("implementer")]!!.allowedTools,
|
||||
)
|
||||
// implementer owns the lifecycle: claim/submit + the full file set + the scope valve.
|
||||
val implTools = graph.stages[StageId("implementer")]!!.allowedTools
|
||||
assertTrue("propose_scope" in implTools, "implementer must be able to propose scope widening")
|
||||
assertTrue(implTools.containsAll(setOf("file_write", "file_edit", "task_update")))
|
||||
// reviewer reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
|
||||
Reference in New Issue
Block a user