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:
2026-07-15 13:17:01 +04:00
parent 9b925e141d
commit d69cb12ce9
17 changed files with 209 additions and 20 deletions
-1
View File
@@ -1 +0,0 @@
1.txt
-1
View File
@@ -1 +0,0 @@
2.txt
-1
View File
@@ -1 +0,0 @@
3.txt
@@ -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)
} }
+8
View File
@@ -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")
@@ -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
@@ -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,
), ),
) )
} }
@@ -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,
@@ -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
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] } // authoritative intent and the stage's declared scope so it can judge whether the files serve
.joinToString("\n\n") // the goal (and stay within scope), not just whether they are internally defect-free — the
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." } // deterministic gates already cover mechanical correctness. Absent intent/scope degrade to the
.take(REVIEW_OBJECTIVE_CAP) // 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}"] }
.joinToString("\n\n")
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
).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
@@ -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
@@ -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
@@ -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.
@@ -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.
} }
} }