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.
This commit is contained in:
2026-06-29 00:45:33 +04:00
parent d26f20c316
commit c1e4c7b25e
14 changed files with 164 additions and 1 deletions
@@ -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),
@@ -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
}
}
@@ -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<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -11,4 +11,5 @@ data class EvaluationContext(
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
val variables: Map<String, String> = emptyMap(),
val artifactContent: Map<ArtifactId, String> = emptyMap(),
val readyTaskCount: Int = 0,
)
@@ -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
+15
View File
@@ -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"
}
}
+25
View File
@@ -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 (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>CORREX Web Client</h1>
<p>Welcome to the orchestration kernel interface.</p>
{/* Basic component placeholders */}
<SessionView api={api} />
<TaskBoard api={api} />
<WorkflowDebugger api={api} />
<EventTimeline api={api} />
</div>
);
};
export default App;
@@ -0,0 +1,8 @@
import React from 'react';
const SessionView: React.FC<{ api: any }> = ({ api }) => {
// Implementation in Step 3
return <div>Session View Component</div>;
};
export default SessionView;
@@ -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())
@@ -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<WorkflowValidationException> { ConditionSpec(type = "nope").toCondition() }
}
}
@@ -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 }