From c1e4c7b25e5a2f1abfc8dadcb3061364958cd2e9 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 29 Jun 2026 00:45:33 +0400 Subject: [PATCH] feat(transitions): tasks_ready predicate for execution loop New TasksReady condition driven by EvaluationContext.readyTaskCount, fed at resolve time via a kernel-declared ReadyTaskCounter interface implemented in apps/server over TaskService (keeps core:kernel decoupled from core:tasks). Loops a graph while ready>0, exits at 0. --- .../kotlin/com/correx/apps/server/Main.kt | 1 + .../server/tasks/ProjectReadyTaskCounter.kt | 31 +++++++++++++++++++ .../DefaultSessionOrchestrator.kt | 3 +- .../kernel/orchestration/ReadyTaskCounter.kt | 13 ++++++++ .../orchestration/SessionOrchestrator.kt | 2 ++ .../core/transitions/conditions/TasksReady.kt | 9 ++++++ .../evaluation/EvaluationContext.kt | 1 + .../conditions/TransitionConditionTest.kt | 12 +++++++ frontend/package.json | 15 +++++++++ frontend/src/App.tsx | 25 +++++++++++++++ .../src/components/session/SessionView.tsx | 8 +++++ .../workflow/ConditionFactory.kt | 2 ++ .../workflow/ConditionFactoryTest.kt | 19 ++++++++++++ .../kotlin/DefaultTransitionResolverTest.kt | 24 ++++++++++++++ 14 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/ProjectReadyTaskCounter.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReadyTaskCounter.kt create mode 100644 core/transitions/src/main/kotlin/com/correx/core/transitions/conditions/TasksReady.kt create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/session/SessionView.tsx create mode 100644 infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ConditionFactoryTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 588c1010..2a04dfaf 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -368,6 +368,7 @@ fun main() { compactionService = journalCompactionService, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, + readyTaskCounter = com.correx.apps.server.tasks.ProjectReadyTaskCounter(eventStore, taskService), ) val workflowRegistry = FileSystemWorkflowRegistry( InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly), diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/ProjectReadyTaskCounter.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/ProjectReadyTaskCounter.kt new file mode 100644 index 00000000..e8a3507a --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/ProjectReadyTaskCounter.kt @@ -0,0 +1,31 @@ +package com.correx.apps.server.tasks + +import com.correx.core.approvals.ProjectIdentity +import com.correx.core.events.events.SessionWorkspaceBoundEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.kernel.orchestration.ReadyTaskCounter +import com.correx.core.tasks.TaskService + +/** + * [ReadyTaskCounter] over the task projection. Resolves the session to its project the same way the + * approval gate does (bound workspace root → [ProjectIdentity.of]) and counts the ready tasks there. + * Pure read over event-derived state, so it stays replay-safe. + * + * Caveat for the execution loop: tasks are keyed by whatever `project` string the decomposer passes + * to task tools; this counts the project derived from the workspace root. Those must agree for the + * loop predicate to fire — Slice 2/4 pins that down. + */ +class ProjectReadyTaskCounter( + private val eventStore: EventStore, + private val taskService: TaskService, +) : ReadyTaskCounter { + override fun count(sessionId: SessionId): Int { + val workspaceRoot = eventStore.read(sessionId) + .mapNotNull { it.payload as? SessionWorkspaceBoundEvent } + .lastOrNull() + ?.workspaceRoot + ?: return 0 + return taskService.ready(ProjectIdentity.of(workspaceRoot)).size + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index b6407959..c05cda9d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -61,7 +61,8 @@ class DefaultSessionOrchestrator( private val compactionService: JournalCompactionService? = null, artifactKindRegistry: ArtifactKindRegistry? = null, repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, -) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway { + readyTaskCounter: ReadyTaskCounter? = null, +) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter), ApprovalGateway { override val tokenizer: Tokenizer? = tokenizer override val cancellations: ConcurrentHashMap = ConcurrentHashMap() diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReadyTaskCounter.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReadyTaskCounter.kt new file mode 100644 index 00000000..f766aaf2 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReadyTaskCounter.kt @@ -0,0 +1,13 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.types.SessionId + +/** + * How many tasks are ready to claim for a session's project, read at transition-resolution time + * (invariant #9: the observation is taken when the predicate runs). Implemented in apps/server over + * the task projection — core:kernel stays decoupled from core:tasks. Null injection ⇒ count 0, so + * the [com.correx.core.transitions.conditions.TasksReady] predicate is simply false. + */ +fun interface ReadyTaskCounter { + fun count(sessionId: SessionId): Int +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 738e9caf..0c490d99 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -187,6 +187,7 @@ abstract class SessionOrchestrator( private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(), private val artifactKindRegistry: ArtifactKindRegistry? = null, private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, + private val readyTaskCounter: ReadyTaskCounter? = null, ) { private val log = LoggerFactory.getLogger(this::class.java) private val eventStore: EventStore = repositories.eventStore @@ -1856,6 +1857,7 @@ abstract class SessionOrchestrator( currentStage = currentStageId, artifacts = artifacts, artifactContent = artifactContent, + readyTaskCount = readyTaskCounter?.count(sessionId) ?: 0, ) return transitionResolver.resolve(graph, ctx) } diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/conditions/TasksReady.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/conditions/TasksReady.kt new file mode 100644 index 00000000..9ef7e71c --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/conditions/TasksReady.kt @@ -0,0 +1,9 @@ +package com.correx.core.transitions.conditions + +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.graph.TransitionCondition + +/** True while the session's project still has tasks ready to claim — the execution loop's gate. */ +object TasksReady : TransitionCondition { + override fun evaluate(context: EvaluationContext) = context.readyTaskCount > 0 +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt index 410cd66c..c7457706 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt @@ -11,4 +11,5 @@ data class EvaluationContext( val artifacts: Map = emptyMap(), val variables: Map = emptyMap(), val artifactContent: Map = emptyMap(), + val readyTaskCount: Int = 0, ) diff --git a/core/transitions/src/test/kotlin/com/correx/core/transitions/conditions/TransitionConditionTest.kt b/core/transitions/src/test/kotlin/com/correx/core/transitions/conditions/TransitionConditionTest.kt index 7ba7966d..453ab6f2 100644 --- a/core/transitions/src/test/kotlin/com/correx/core/transitions/conditions/TransitionConditionTest.kt +++ b/core/transitions/src/test/kotlin/com/correx/core/transitions/conditions/TransitionConditionTest.kt @@ -30,6 +30,18 @@ class TransitionConditionTest { assertTrue(AlwaysTrue.evaluate(baseContext)) } + // TasksReady + + @Test + fun `TasksReady true when ready count positive`() { + assertTrue(TasksReady.evaluate(baseContext.copy(readyTaskCount = 1))) + } + + @Test + fun `TasksReady false when no ready tasks`() { + assertFalse(TasksReady.evaluate(baseContext)) + } + // ArtifactPresent @Test diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..432592b3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "correx-web-ui", + "version": "1.0.0", + "description": "Web client for CORREX orchestration kernel", + "main": "./src/App.tsx", + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..14d2eaac --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import SessionView from './components/session/SessionView'; +import TaskBoard from './components/task/TaskBoard'; +import WorkflowDebugger from './components/workflow/WorkflowDebugger'; +import EventTimeline from './components/state/EventTimeline'; +import { api } from './services/api'; +import { actions } from './services/actions'; + +const App: React.FC = () => { + // Placeholder for routing or state management + return ( +
+

CORREX Web Client

+

Welcome to the orchestration kernel interface.

+ + {/* Basic component placeholders */} + + + + +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/frontend/src/components/session/SessionView.tsx b/frontend/src/components/session/SessionView.tsx new file mode 100644 index 00000000..c50823f7 --- /dev/null +++ b/frontend/src/components/session/SessionView.tsx @@ -0,0 +1,8 @@ +import React from 'react'; + +const SessionView: React.FC<{ api: any }> = ({ api }) => { + // Implementation in Step 3 + return
Session View Component
; +}; + +export default SessionView; \ No newline at end of file diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ConditionFactory.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ConditionFactory.kt index fb3723ca..501fbdbf 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ConditionFactory.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ConditionFactory.kt @@ -10,6 +10,7 @@ import com.correx.core.transitions.conditions.ArtifactPresent import com.correx.core.transitions.conditions.ArtifactValidated import com.correx.core.transitions.conditions.FieldOperator import com.correx.core.transitions.conditions.Not +import com.correx.core.transitions.conditions.TasksReady import com.correx.core.transitions.conditions.VariableEquals import com.correx.core.transitions.graph.TransitionCondition @@ -20,6 +21,7 @@ fun ConditionSpec.toCondition(): TransitionCondition = when (type) { "artifact_validated" -> buildArtifactValidated() "variable_equals" -> buildVariableEquals() "artifact_field_equals" -> buildArtifactFieldEquals() + "tasks_ready" -> TasksReady "all_of" -> AllOf(conditions.map { it.toCondition() }) "any_of" -> AnyOf(conditions.map { it.toCondition() }) "not" -> Not((condition ?: error("condition required for not")).toCondition()) diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ConditionFactoryTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ConditionFactoryTest.kt new file mode 100644 index 00000000..6e11a964 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ConditionFactoryTest.kt @@ -0,0 +1,19 @@ +package com.correx.infrastructure.workflow + +import com.correx.core.transitions.conditions.TasksReady +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertSame + +class ConditionFactoryTest { + + @Test + fun `tasks_ready maps to TasksReady`() { + assertSame(TasksReady, ConditionSpec(type = "tasks_ready").toCondition()) + } + + @Test + fun `unknown type rejected`() { + assertThrows { ConditionSpec(type = "nope").toCondition() } + } +} diff --git a/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt b/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt index c5afb6a4..ca634d8f 100644 --- a/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt +++ b/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt @@ -1,5 +1,7 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import com.correx.core.transitions.conditions.Not +import com.correx.core.transitions.conditions.TasksReady import com.correx.core.events.types.TransitionId import com.correx.core.transitions.evaluation.EvaluationContext import com.correx.core.transitions.evaluation.TransitionConditionEvaluator @@ -210,6 +212,28 @@ class DefaultTransitionResolverTest { ) } + @Test + fun `TasksReady loops while ready tasks remain and exits at zero`() { + val graph = graph( + start = "claim", + transitions = setOf( + edge(id = "loop", from = "claim", to = "claim", condition = TasksReady), + edge(id = "exit", from = "claim", to = "done", condition = Not(TasksReady)), + ), + stages = setOf("claim", "done"), + ) + val base = context(currentStage = "claim") + + assertEquals( + StageId("claim"), + (resolver.resolve(graph, base.copy(readyTaskCount = 2)) as TransitionDecision.Move).to, + ) + assertEquals( + StageId("done"), + (resolver.resolve(graph, base.copy(readyTaskCount = 0)) as TransitionDecision.Move).to, + ) + } + private fun alwaysTrue() = TransitionCondition { true }