refactor: decomposition WIP (orchestrator/server/execution-plan)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhFXmKe4WisSSPf9LrmmTg
This commit is contained in:
@@ -542,6 +542,10 @@ class ServerModule(
|
|||||||
val planAlreadyLocked = eventStore.read(sessionId)
|
val planAlreadyLocked = eventStore.read(sessionId)
|
||||||
.any { it.payload is ExecutionPlanLockedEvent }
|
.any { it.payload is ExecutionPlanLockedEvent }
|
||||||
if (planAlreadyLocked) return
|
if (planAlreadyLocked) return
|
||||||
|
// completeWorkflow evicts artifactContentCache on the planning graph's completion (#54),
|
||||||
|
// so the execution_plan slot lockAndRun reads is gone by now. Rebuild it from the durable
|
||||||
|
// ArtifactContentStoredEvent before handing off. Rehydrate-safe and idempotent.
|
||||||
|
orchestrator.rehydrate(sessionId)
|
||||||
freestyleDriver?.lockAndRun(sessionId)
|
freestyleDriver?.lockAndRun(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ subprojects {
|
|||||||
apply plugin: "io.gitlab.arturbosch.detekt"
|
apply plugin: "io.gitlab.arturbosch.detekt"
|
||||||
apply plugin: "org.jetbrains.kotlinx.kover"
|
apply plugin: "org.jetbrains.kotlinx.kover"
|
||||||
|
|
||||||
|
// Unique jar base names from the full project path (core:inference -> core-inference).
|
||||||
|
// Sibling modules share leaf names (core:inference vs infrastructure:inference, core:tools
|
||||||
|
// vs infrastructure:tools), which collide in the flat application-plugin lib dir and drop
|
||||||
|
// classes at runtime. Derive per-path names so every jar lands distinctly.
|
||||||
|
tasks.withType(Jar).configureEach {
|
||||||
|
archiveBaseName = project.path.substring(1).replace(':', '-')
|
||||||
|
}
|
||||||
|
|
||||||
detekt {
|
detekt {
|
||||||
toolVersion = "1.23.7"
|
toolVersion = "1.23.7"
|
||||||
config.setFrom("$rootDir/detekt.yml")
|
config.setFrom("$rootDir/detekt.yml")
|
||||||
|
|||||||
+1
@@ -24,6 +24,7 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
|
|||||||
|
|
||||||
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
|
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
|
||||||
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
|
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
|
||||||
|
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
|
|||||||
+40
-4
@@ -16,6 +16,7 @@ import com.correx.core.events.events.RepoMapComputedEvent
|
|||||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
|
import com.correx.core.events.events.InitialIntentEvent
|
||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.ClarificationRequestId
|
import com.correx.core.events.types.ClarificationRequestId
|
||||||
@@ -120,8 +121,43 @@ internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: Ses
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects the operator's answers to a stage's open questions as an L2 USER entry, so the stage
|
* Injects the initial user intent (the freeform request that started the run) as a pinned L0
|
||||||
* sees its own questions resolved on the clarification re-run. Correlates each answer's
|
* SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent
|
||||||
|
* is the single most load-bearing constraint of a run, yet it previously reached normal stages only
|
||||||
|
* as a repo-map retrieval seed (repoKnowledgeQuery) or, on the rare Tier-2 recovery path, the
|
||||||
|
* arbiter ticket — so an implementer could drift from the goal with the goal itself absent from its
|
||||||
|
* authoritative context. Standing at L0/SYSTEM it is weighted as an instruction and never dropped
|
||||||
|
* under budget. Absent for fixed-task workflows (no InitialIntentEvent) → empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): List<ContextEntry> {
|
||||||
|
val intent = initialIntent(sessionId)?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||||
|
val content = "## Authoritative intent\nThe operator's request that started this run. Everything " +
|
||||||
|
"you produce must serve it; do not drift from it or expand its scope:\n$intent"
|
||||||
|
return listOf(
|
||||||
|
ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L0,
|
||||||
|
content = content,
|
||||||
|
sourceType = "initialIntent",
|
||||||
|
sourceId = sessionId.value,
|
||||||
|
tokenEstimate = estimateTokens(content),
|
||||||
|
role = EntryRole.SYSTEM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The freeform request that started the run, or null for a fixed-task workflow. */
|
||||||
|
internal fun SessionOrchestrator.initialIntent(sessionId: SessionId): String? =
|
||||||
|
eventStore.read(sessionId)
|
||||||
|
.mapNotNull { it.payload as? InitialIntentEvent }
|
||||||
|
.firstOrNull()?.intent?.trim()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the operator's answers to a stage's open questions as a pinned L0 SYSTEM entry, so the
|
||||||
|
* stage sees its own questions resolved on the clarification re-run. The prompt calls these answers
|
||||||
|
* "authoritative", so they are placed as authoritative standing instructions (L0/SYSTEM), not a
|
||||||
|
* droppable L2 USER turn — matching the mechanics to the stated authority. Correlates each answer's
|
||||||
* questionId back to the prompt recorded on the [ClarificationRequestedEvent].
|
* questionId back to the prompt recorded on the [ClarificationRequestedEvent].
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -141,12 +177,12 @@ internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(session
|
|||||||
return listOf(
|
return listOf(
|
||||||
ContextEntry(
|
ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
layer = ContextLayer.L2,
|
layer = ContextLayer.L0,
|
||||||
content = content,
|
content = content,
|
||||||
sourceType = "clarificationAnswer",
|
sourceType = "clarificationAnswer",
|
||||||
sourceId = sessionId.value,
|
sourceId = sessionId.value,
|
||||||
tokenEstimate = estimateTokens(content),
|
tokenEstimate = estimateTokens(content),
|
||||||
role = EntryRole.USER,
|
role = EntryRole.SYSTEM,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -144,6 +144,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
?: ResponseFormat.Text
|
?: ResponseFormat.Text
|
||||||
|
|
||||||
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
|
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
|
||||||
|
val intentEntries = buildIntentEntry(sessionId)
|
||||||
val steeringEntries = buildSteeringNoteEntries(sessionId)
|
val steeringEntries = buildSteeringNoteEntries(sessionId)
|
||||||
val clarificationEntries = buildClarificationAnswerEntries(sessionId)
|
val clarificationEntries = buildClarificationAnswerEntries(sessionId)
|
||||||
|
|
||||||
@@ -238,7 +239,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||||
?.let { listOf(it) } ?: emptyList()
|
?.let { listOf(it) } ?: emptyList()
|
||||||
var accumulatedEntries = stampBuckets(
|
var accumulatedEntries = stampBuckets(
|
||||||
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||||
|
|||||||
+13
-2
@@ -190,11 +190,22 @@ internal suspend fun SessionOrchestrator.runReviewGate(
|
|||||||
val files = stageWrittenPaths(sessionId, stageId)
|
val files = stageWrittenPaths(sessionId, stageId)
|
||||||
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
|
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||||
|
|
||||||
val objective = stageConfig.needs
|
// Goal-conformance framing (architecture-conformance 2026-07-14): lead the reviewer with the
|
||||||
|
// authoritative intent and the stage's declared scope so it can judge whether the files serve
|
||||||
|
// the goal (and stay within scope), not just whether they are internally defect-free — the
|
||||||
|
// deterministic gates already cover mechanical correctness. Absent intent/scope degrade to the
|
||||||
|
// prior upstream-artifact objective.
|
||||||
|
val intentHeader = initialIntent(sessionId)?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "## Authoritative intent (the goal these files must serve)\n$it\n\n" } ?: ""
|
||||||
|
val scopeHeader = stageConfig.touches.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { "## This stage's declared scope\n${it.joinToString(", ")}\n\n" } ?: ""
|
||||||
|
val objective = (
|
||||||
|
intentHeader + scopeHeader +
|
||||||
|
stageConfig.needs
|
||||||
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
|
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
|
||||||
.joinToString("\n\n")
|
.joinToString("\n\n")
|
||||||
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
|
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
|
||||||
.take(REVIEW_OBJECTIVE_CAP)
|
).take(REVIEW_OBJECTIVE_CAP)
|
||||||
|
|
||||||
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
|
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ data class StageConfig(
|
|||||||
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
|
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
|
||||||
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
|
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
|
||||||
val expectedFiles: List<String> = emptyList(),
|
val expectedFiles: List<String> = emptyList(),
|
||||||
|
// Declared scope for this stage (architecture-conformance, design 2026-07-14): the
|
||||||
|
// workspace-relative globs the stage's intent is confined to (e.g. a frontend-client stage
|
||||||
|
// declares `frontend/**`). Distinct from [writeManifest], which is DERIVED from the concrete
|
||||||
|
// `writes` and so cannot contradict them: `touches` is the architect's separate statement of
|
||||||
|
// intent, and the plan compiler rejects a plan whose declared `writes` escape it (scope creep
|
||||||
|
// caught at plan time, before any code is written). Also fed to the semantic reviewer so it can
|
||||||
|
// judge goal-conformance against the declared scope. Empty = no scope constraint (default).
|
||||||
|
val touches: List<String> = emptyList(),
|
||||||
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
|
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
|
||||||
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
|
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
|
||||||
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
|
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
"produces": "<unique artifact_id this stage emits, your choice of name>",
|
"produces": "<unique artifact_id this stage emits, your choice of name>",
|
||||||
"kind": "<one of the available artifact kinds listed in your context>",
|
"kind": "<one of the available artifact kinds listed in your context>",
|
||||||
"needs": ["<artifact_id consumed from an earlier stage>"],
|
"needs": ["<artifact_id consumed from an earlier stage>"],
|
||||||
"tools": ["file_read", "file_write", "file_edit", "shell"]
|
"tools": ["file_read", "file_write", "file_edit", "shell"],
|
||||||
|
"writes": ["<workspace-relative path or glob this stage will write>"],
|
||||||
|
"touches": ["<glob the stage is confined to, e.g. frontend/**>"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edges": [
|
"edges": [
|
||||||
@@ -79,6 +81,14 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
decompose tasks here — task 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.
|
||||||
|
- **Declare `touches` to fence a stage to its area.** When a stage's job belongs to one part of
|
||||||
|
the tree (a frontend/client stage → `["frontend/**"]`; a stage that edits only the API layer →
|
||||||
|
`["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes`
|
||||||
|
must fall within its `touches`, or the plan is rejected at compile time. This is how you stop a
|
||||||
|
stage from silently widening past its intent — e.g. a "build the web approval **client**" stage
|
||||||
|
that also starts adding backend routes. Set `touches` narrowly on any stage whose scope is a
|
||||||
|
single area; omit it only for genuinely cross-cutting stages. If a change truly needs a
|
||||||
|
different area, give it its own stage that owns that area rather than widening an unrelated one.
|
||||||
- **Prefer the real scaffolder over hand-writing config.** A stage that sets up a project should
|
- **Prefer the real scaffolder over hand-writing config.** A stage that sets up a project should
|
||||||
use the package manager's own generator — e.g. `npm create vite@latest <dir> -- --template
|
use the package manager's own generator — e.g. `npm create vite@latest <dir> -- --template
|
||||||
react-ts` — because it produces a correct, complete, current file set (a hand-written
|
react-ts` — because it produces a correct, complete, current file set (a hand-written
|
||||||
|
|||||||
+4
-3
@@ -154,9 +154,6 @@ class DefaultModelManager(
|
|||||||
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
|
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
|
||||||
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
|
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
|
||||||
while (Clock.System.now().toEpochMilliseconds() < endTime) {
|
while (Clock.System.now().toEpochMilliseconds() < endTime) {
|
||||||
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
|
||||||
// timeout is pure dead time. Bail as soon as it's gone.
|
|
||||||
if (!process.isAlive) return false
|
|
||||||
try {
|
try {
|
||||||
val response = httpClient.get("http://$host:$port/health").body<String>()
|
val response = httpClient.get("http://$host:$port/health").body<String>()
|
||||||
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
|
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
|
||||||
@@ -165,6 +162,10 @@ class DefaultModelManager(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Continue polling
|
// Continue polling
|
||||||
}
|
}
|
||||||
|
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
||||||
|
// timeout is pure dead time. Bail as soon as it's gone — but only after probing health,
|
||||||
|
// so a server that's already answering isn't missed on a lost startup/exit race.
|
||||||
|
if (!process.isAlive) return false
|
||||||
delay(POLL_INTERVAL)
|
delay(POLL_INTERVAL)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
+35
@@ -14,6 +14,8 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
|||||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import java.nio.file.FileSystems
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
private const val TERMINAL = "done"
|
private const val TERMINAL = "done"
|
||||||
private const val RECOVERY_STAGE = "recovery"
|
private const val RECOVERY_STAGE = "recovery"
|
||||||
@@ -88,6 +90,7 @@ class ExecutionPlanCompiler(
|
|||||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||||
validateTools(plan)
|
validateTools(plan)
|
||||||
|
validateScope(plan)
|
||||||
|
|
||||||
// Parse + validate every stage's declared build_expectation up front — also feeds the
|
// Parse + validate every stage's declared build_expectation up front — also feeds the
|
||||||
// deterministic build-gate guarantee below.
|
// deterministic build-gate guarantee below.
|
||||||
@@ -144,6 +147,7 @@ class ExecutionPlanCompiler(
|
|||||||
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||||
// not existence guarantees, so they are excluded here.
|
// not existence guarantees, so they are excluded here.
|
||||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||||
|
touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
buildExpectation = declaredExpectations.getValue(s.id),
|
buildExpectation = declaredExpectations.getValue(s.id),
|
||||||
autoBuildGate = s.id == autoGateStageId,
|
autoBuildGate = s.id == autoGateStageId,
|
||||||
semanticReview = s.semanticReview,
|
semanticReview = s.semanticReview,
|
||||||
@@ -227,6 +231,37 @@ class ExecutionPlanCompiler(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture-conformance scope check (design 2026-07-14). When a stage declares a `touches`
|
||||||
|
* scope, every concrete (non-glob) path it also declares in `writes` must fall within it —
|
||||||
|
* otherwise the stage has quietly widened past its stated intent (the observed failure: a
|
||||||
|
* "web-approval-CLIENT" stage that also lists backend routes in its writes). Rejecting here, at
|
||||||
|
* plan-compile time, hands the plan back to the architect via the existing plan-compile gate
|
||||||
|
* BEFORE any code is written — the [writeManifest] guard cannot catch this because it is DERIVED
|
||||||
|
* from the same `writes` and so can never contradict them. A glob in `writes` is a
|
||||||
|
* write-permission scope, not a concrete target, so it is not checked against `touches` here.
|
||||||
|
* A stage with no `touches` is unconstrained (opt-in), so existing plans are unaffected.
|
||||||
|
*/
|
||||||
|
private fun validateScope(plan: ExecutionPlanModel) {
|
||||||
|
for (s in plan.stages) {
|
||||||
|
val touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
if (touches.isEmpty()) continue
|
||||||
|
val matchers = touches.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||||
|
val escapees = s.writes
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && !isGlob(it) }
|
||||||
|
.filterNot { path -> matchers.any { it.matches(Path.of(path)) } }
|
||||||
|
if (escapees.isNotEmpty()) {
|
||||||
|
throw WorkflowValidationException(
|
||||||
|
"stage '${s.id}' declares scope touches=$touches but writes files outside it: " +
|
||||||
|
"${escapees.sorted()} — either keep the stage within its declared scope, or, " +
|
||||||
|
"if the change genuinely belongs to another area, widen `touches` or move " +
|
||||||
|
"those writes to a stage that owns that area.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A field-equals edge can only ever fire if the producing stage's kind schema declares
|
* 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
|
* that field — the kind's schema is also the LLM response format, so an undeclared field
|
||||||
|
|||||||
+6
@@ -21,6 +21,12 @@ data class PlanStage(
|
|||||||
// contribution (the static manifest, if any, still applies). The planner emits this under
|
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||||
// the JSON key `writes`.
|
// the JSON key `writes`.
|
||||||
val writes: List<String> = emptyList(),
|
val writes: List<String> = emptyList(),
|
||||||
|
// Declared scope globs this stage's intent is confined to (architecture-conformance,
|
||||||
|
// 2026-07-14). The planner emits this under the JSON key `touches`; every concrete path in
|
||||||
|
// `writes` must fall within it or the plan is rejected at compile time. Absent/empty = no
|
||||||
|
// scope constraint. Set it on narrow stages (a frontend-client stage → ["frontend/**"]) so a
|
||||||
|
// stage that quietly widens into the backend is caught before it writes.
|
||||||
|
val touches: List<String> = emptyList(),
|
||||||
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
|
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
|
||||||
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
|
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
|
||||||
// project profile's typecheck/build/test command run as a deterministic Gate 4.
|
// project profile's typecheck/build/test command run as a deterministic Gate 4.
|
||||||
|
|||||||
+54
@@ -140,6 +140,60 @@ class ExecutionPlanCompilerTest {
|
|||||||
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
|
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `writes escaping declared touches scope throws WorkflowValidationException naming the escapee`() {
|
||||||
|
// The observed failure: a "client" stage confined to frontend that also lists a backend file.
|
||||||
|
val plan = """
|
||||||
|
{
|
||||||
|
"goal": "build the approval client",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": "impl_client",
|
||||||
|
"prompt": "Build the web approval client",
|
||||||
|
"produces": "patch",
|
||||||
|
"needs": [],
|
||||||
|
"tools": ["file_write"],
|
||||||
|
"touches": ["frontend/**"],
|
||||||
|
"writes": ["frontend/src/pages/Approvals.tsx", "apps/server/routes/ApprovalStageRoute.kt"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "impl_client", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val ex = assertThrows<WorkflowValidationException> { compiler.compile(plan, "scope-workflow") }
|
||||||
|
assertTrue(
|
||||||
|
ex.message!!.contains("apps/server/routes/ApprovalStageRoute.kt"),
|
||||||
|
"the rejection names the out-of-scope write: ${ex.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `writes within declared touches scope compile, and globs are not scope-checked`() {
|
||||||
|
val plan = """
|
||||||
|
{
|
||||||
|
"goal": "build the approval client",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": "impl_client",
|
||||||
|
"prompt": "Build the web approval client",
|
||||||
|
"produces": "patch",
|
||||||
|
"needs": [],
|
||||||
|
"tools": ["file_write"],
|
||||||
|
"touches": ["frontend/**"],
|
||||||
|
"writes": ["frontend/src/pages/Approvals.tsx", "frontend/src/**"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "impl_client", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val graph = compiler.compile(plan, "scope-ok-workflow")
|
||||||
|
assertEquals(listOf("frontend/**"), graph.stages.getValue(StageId("impl_client")).touches)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
|||||||
import com.correx.testing.fixtures.EventFixtures
|
import com.correx.testing.fixtures.EventFixtures
|
||||||
import com.correx.testing.fixtures.inference.MockTokenizer
|
import com.correx.testing.fixtures.inference.MockTokenizer
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
@@ -694,7 +695,9 @@ class TalkieFacadeTest {
|
|||||||
payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")),
|
payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val after = reader.activeIdeas()
|
// IdeaReader folds post-construction events via subscribeAll on a background dispatcher, so
|
||||||
|
// the discard lands asynchronously — poll briefly instead of reading on the same tick.
|
||||||
|
val after = eventually { reader.activeIdeas().takeIf { it.size == 1 } }
|
||||||
assertEquals(listOf("cache the repo map"), after.map { it.text })
|
assertEquals(listOf("cache the repo map"), after.map { it.text })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,10 +1471,22 @@ class TalkieFacadeTest {
|
|||||||
budgetLimit = 5000,
|
budgetLimit = 5000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private suspend fun <T> eventually(timeoutMs: Long = 2_000L, block: () -> T?): T {
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
block()?.let { return it }
|
||||||
|
kotlinx.coroutines.delay(10)
|
||||||
|
}
|
||||||
|
return requireNotNull(block()) { "condition not met within ${timeoutMs}ms" }
|
||||||
|
}
|
||||||
|
|
||||||
private class MapBackedEventStore : EventStore {
|
private class MapBackedEventStore : EventStore {
|
||||||
val appendedEvents = mutableListOf<NewEvent>()
|
val appendedEvents = mutableListOf<NewEvent>()
|
||||||
private val storedEvents: MutableMap<EventId, StoredEvent> = mutableMapOf()
|
private val storedEvents: MutableMap<EventId, StoredEvent> = mutableMapOf()
|
||||||
private var nextSequence = 1L
|
private var nextSequence = 1L
|
||||||
|
// replay (not just extraBufferCapacity) so a collector that registers after an emit still
|
||||||
|
// receives it — otherwise there's a subscribe-vs-emit race with IdeaReader's launched collect.
|
||||||
|
private val liveEvents = MutableSharedFlow<StoredEvent>(replay = 1024)
|
||||||
|
|
||||||
override suspend fun append(event: NewEvent): StoredEvent {
|
override suspend fun append(event: NewEvent): StoredEvent {
|
||||||
appendedEvents.add(event)
|
appendedEvents.add(event)
|
||||||
@@ -1482,6 +1497,7 @@ class TalkieFacadeTest {
|
|||||||
payload = event.payload,
|
payload = event.payload,
|
||||||
)
|
)
|
||||||
storedEvents[event.metadata.eventId] = stored
|
storedEvents[event.metadata.eventId] = stored
|
||||||
|
liveEvents.tryEmit(stored)
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,7 +1524,7 @@ class TalkieFacadeTest {
|
|||||||
|
|
||||||
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
||||||
|
|
||||||
override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context")
|
override fun subscribeAll(): Flow<StoredEvent> = liveEvents
|
||||||
|
|
||||||
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context")
|
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class DecisionJournalReplayTest {
|
|||||||
|
|
||||||
assertEquals(a, b)
|
assertEquals(a, b)
|
||||||
assertTrue(a.contains("use jwt"))
|
assertTrue(a.contains("use jwt"))
|
||||||
assertTrue(a.contains("a → b"))
|
// TRANSITION records are bookkeeping — deliberately excluded from the render (see
|
||||||
|
// DecisionJournalRenderer.AGENT_RELEVANT_KINDS), so "a → b" is intentionally absent.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user