merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
@@ -17,6 +17,12 @@ private const val TERMINAL = "done"
|
||||
|
||||
class ExecutionPlanCompiler(
|
||||
private val registry: ArtifactKindRegistry,
|
||||
// Names of every registered tool. A stage that references a tool the runtime can't resolve
|
||||
// is dropped silently at run time (mapNotNull), so the stage runs toolless — invisible
|
||||
// without a live run. Validating here turns that into a compile-time rejection (which the
|
||||
// freestyle driver feeds back for a retry). Empty = the caller didn't supply the tool
|
||||
// universe, so validation is skipped (preserves the bare `ExecutionPlanCompiler(registry)`).
|
||||
private val knownTools: Set<String> = emptySet(),
|
||||
) {
|
||||
private val mapper = JsonMapper.builder()
|
||||
.addModule(kotlinModule())
|
||||
@@ -27,6 +33,7 @@ class ExecutionPlanCompiler(
|
||||
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||
validateTools(plan)
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||
@@ -34,10 +41,20 @@ class ExecutionPlanCompiler(
|
||||
val kindId = s.kind ?: s.produces
|
||||
val kind = registry.get(kindId)
|
||||
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
|
||||
// Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG
|
||||
// §B-§2). Plan stages carry no separate static `writes` glob list today, so the static
|
||||
// baseline here is empty and the plan-derived paths form the manifest; the union is kept
|
||||
// explicit so a future static source widens rather than replaces. Sorted for a
|
||||
// deterministic, replay-stable manifest.
|
||||
val writeManifest = PlanDerivedManifest.combine(
|
||||
staticManifest = emptyList(),
|
||||
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||
).sorted()
|
||||
StageId(s.id) to StageConfig(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
allowedTools = s.tools.toSet(),
|
||||
writeManifest = writeManifest,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
}
|
||||
@@ -71,6 +88,22 @@ class ExecutionPlanCompiler(
|
||||
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tool a stage names must be a registered tool, else the runtime silently drops it
|
||||
* (resolve → null → mapNotNull) and the stage runs without it. Skipped when [knownTools] is
|
||||
* empty (the caller didn't supply the tool universe).
|
||||
*/
|
||||
private fun validateTools(plan: ExecutionPlanModel) {
|
||||
if (knownTools.isEmpty()) return
|
||||
val offenders = plan.stages.flatMap { s -> s.tools.filter { it !in knownTools }.map { s.id to it } }
|
||||
if (offenders.isEmpty()) return
|
||||
val detail = offenders.joinToString(", ") { (stage, tool) -> "'$tool' in stage '$stage'" }
|
||||
throw WorkflowValidationException(
|
||||
"execution_plan references unknown tool(s): $detail — valid tools: " +
|
||||
knownTools.sorted().joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A field-equals edge can only ever fire if the producing stage's kind schema declares
|
||||
* that field — the kind's schema is also the LLM response format, so an undeclared field
|
||||
|
||||
+6
@@ -15,6 +15,12 @@ data class PlanStage(
|
||||
val kind: String? = null,
|
||||
val needs: List<String> = emptyList(),
|
||||
val tools: List<String> = emptyList(),
|
||||
// Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2).
|
||||
// Drives the plan-derived diff manifest: these are unioned with the static per-stage
|
||||
// write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived
|
||||
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||
// the JSON key `writes`.
|
||||
val writes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class PlanEdge(
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
/**
|
||||
* Plan-derived diff manifest (BACKLOG §B-§2).
|
||||
*
|
||||
* The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a
|
||||
* STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the
|
||||
* allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file
|
||||
* paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked
|
||||
* plan rather than only hand-written globs.
|
||||
*
|
||||
* The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two,
|
||||
* and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are
|
||||
* matched with the SAME glob/containment semantics the static manifest already uses — no parallel
|
||||
* matcher is introduced.
|
||||
*
|
||||
* ## Extraction rule
|
||||
* Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the
|
||||
* plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots*
|
||||
* (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets.
|
||||
* Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set,
|
||||
* blank/whitespace entries are dropped, and duplicates collapse.
|
||||
*/
|
||||
object PlanDerivedManifest {
|
||||
|
||||
/**
|
||||
* The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its
|
||||
* declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty
|
||||
* set (the static manifest, if any, still governs). Deterministic for a given input.
|
||||
*/
|
||||
fun deriveAllowedWrites(planStage: PlanStage): Set<String> =
|
||||
planStage.writes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Unions the static per-stage write globs with the plan-derived set. A write is allowed if it
|
||||
* matches EITHER source. Blank entries are dropped so the combined set carries only matchable
|
||||
* globs. The static path is never narrowed — the result only ever widens the static baseline by
|
||||
* the plan-derived paths.
|
||||
*/
|
||||
fun combine(staticManifest: Collection<String>, planDerived: Set<String>): Set<String> =
|
||||
(staticManifest + planDerived)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
}
|
||||
+81
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ExecutionPlanCompilerTest {
|
||||
|
||||
@@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest {
|
||||
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan-declared writes are derived into the stage write manifest`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "implement a feature",
|
||||
"stages": [
|
||||
{
|
||||
"id": "impl",
|
||||
"prompt": "Implement it",
|
||||
"produces": "patch",
|
||||
"needs": [],
|
||||
"tools": ["file_write"],
|
||||
"writes": ["core/feature/**", "docs/feature.md"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "impl", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = compiler.compile(plan, "manifest-workflow")
|
||||
val stage = graph.stages.values.single()
|
||||
assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage without declared writes has an empty write manifest`() {
|
||||
val graph = compiler.compile(validPlan, "no-writes-workflow")
|
||||
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||
@@ -241,4 +273,53 @@ class ExecutionPlanCompilerTest {
|
||||
fun `malformed JSON throws WorkflowValidationException`() {
|
||||
assertThrows<WorkflowValidationException> { compiler.compile("not-json", "bad-workflow") }
|
||||
}
|
||||
|
||||
// A compiler that knows the tool universe, so plan tool names are validated.
|
||||
private val toolAware = ExecutionPlanCompiler(
|
||||
registry,
|
||||
knownTools = setOf("ShellTool", "file_write", "task_context", "task_update"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `unknown tool name throws WorkflowValidationException naming the offender`() {
|
||||
// The runtime would silently drop a misspelled tool; here it fails to compile instead.
|
||||
val bad = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"task_updates\"]")
|
||||
val ex = assertThrows<WorkflowValidationException> { toolAware.compile(bad, "bad-workflow") }
|
||||
assertEquals(true, ex.message?.contains("task_updates"))
|
||||
assertEquals(true, ex.message?.contains("analyse"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan threading registered task tools compiles`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "implement and review a tracked task",
|
||||
"stages": [
|
||||
{
|
||||
"id": "implement", "prompt": "claim auth-1, build, submit", "produces": "patch",
|
||||
"needs": [], "tools": ["file_write", "task_context", "task_update"]
|
||||
},
|
||||
{
|
||||
"id": "review", "prompt": "review and complete auth-1", "produces": "patch",
|
||||
"needs": ["patch"], "tools": ["task_context", "task_update"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "implement", "to": "review", "condition": { "type": "always_true" } },
|
||||
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = toolAware.compile(plan, "tracked-workflow")
|
||||
val implement = graph.stages.values.first { it.metadata["promptInline"] == "claim auth-1, build, submit" }
|
||||
assertTrue(implement.allowedTools.containsAll(setOf("task_context", "task_update")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool validation is skipped when the tool universe is unknown`() {
|
||||
// The default compiler has no knownTools, so an arbitrary tool name still compiles.
|
||||
val plan = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"anything_goes\"]")
|
||||
val graph = compiler.compile(plan, "unvalidated-workflow")
|
||||
assertEquals(2, graph.stages.size)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -69,5 +69,12 @@ class FreestylePlanningWorkflowTest {
|
||||
assertTrue(architectToDone.condition is ArtifactValidated)
|
||||
|
||||
assertEquals(2, graph.transitions.size)
|
||||
|
||||
// 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", "task_decompose"),
|
||||
graph.stages[StageId("analyst")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanDerivedManifestTest {
|
||||
|
||||
@Test
|
||||
fun `derives the declared write paths of a plan stage`() {
|
||||
val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md"))
|
||||
assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage with no declared writes derives an empty set`() {
|
||||
val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x"))
|
||||
assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple files are all derived and blank entries dropped`() {
|
||||
val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt"))
|
||||
assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combine unions the static globs with the plan-derived set`() {
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**"),
|
||||
planDerived = setOf("docs/x.md"),
|
||||
)
|
||||
assertEquals(setOf("core/**", "docs/x.md"), combined)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combine drops blank entries and de-duplicates across sources`() {
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**", " ", "shared.kt"),
|
||||
planDerived = setOf("shared.kt", "docs/x.md", ""),
|
||||
)
|
||||
assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined manifest matches via the same glob semantics the static manifest uses`() {
|
||||
// The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2
|
||||
// ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write
|
||||
// allowed only by the plan-derived entry, one allowed only by the static glob, and one in
|
||||
// neither resolve as expected under that exact matcher — no parallel matcher is introduced.
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**"),
|
||||
planDerived = setOf("docs/x.md"),
|
||||
)
|
||||
val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||
fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) }
|
||||
|
||||
assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt")
|
||||
assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md")
|
||||
assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied")
|
||||
}
|
||||
}
|
||||
+31
@@ -7,8 +7,11 @@ 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 org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
@@ -93,4 +96,32 @@ class ReviewLoopWorkflowTest {
|
||||
val changes = graph.transitions.first { it.to == StageId("implement") }.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.NEQ, changes.operator)
|
||||
}
|
||||
|
||||
private fun repoFile(rel: String): Path? {
|
||||
var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
|
||||
while (dir != null) {
|
||||
val candidate = dir.resolve(rel)
|
||||
if (candidate.exists()) return candidate
|
||||
dir = dir.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shipped review_loop grants the task tools to both stages`() {
|
||||
val path = repoFile("examples/workflows/review_loop.toml")
|
||||
assumeTrue(path != null, "review_loop.toml not found from ${System.getProperty("user.dir")}")
|
||||
val graph = loader.load(path!!)
|
||||
|
||||
// implement claims/submits and may create a task, alongside its file write.
|
||||
assertEquals(
|
||||
setOf("file_write", "task_create", "task_update", "task_context", "task_search"),
|
||||
graph.stages[StageId("implement")]!!.allowedTools,
|
||||
)
|
||||
// review reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
graph.stages[StageId("review")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -71,4 +71,28 @@ class RolePipelineWorkflowTest {
|
||||
.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.NEQ, loop.operator)
|
||||
}
|
||||
|
||||
@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!!)
|
||||
|
||||
// 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,
|
||||
)
|
||||
// reviewer reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
graph.stages[StageId("reviewer")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user