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:
@@ -368,6 +368,7 @@ fun main() {
|
|||||||
compactionService = journalCompactionService,
|
compactionService = journalCompactionService,
|
||||||
artifactKindRegistry = artifactKindRegistry,
|
artifactKindRegistry = artifactKindRegistry,
|
||||||
repoKnowledgeRetriever = repoKnowledgeRetriever,
|
repoKnowledgeRetriever = repoKnowledgeRetriever,
|
||||||
|
readyTaskCounter = com.correx.apps.server.tasks.ProjectReadyTaskCounter(eventStore, taskService),
|
||||||
)
|
)
|
||||||
val workflowRegistry = FileSystemWorkflowRegistry(
|
val workflowRegistry = FileSystemWorkflowRegistry(
|
||||||
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -61,7 +61,8 @@ class DefaultSessionOrchestrator(
|
|||||||
private val compactionService: JournalCompactionService? = null,
|
private val compactionService: JournalCompactionService? = null,
|
||||||
artifactKindRegistry: ArtifactKindRegistry? = null,
|
artifactKindRegistry: ArtifactKindRegistry? = null,
|
||||||
repoKnowledgeRetriever: RepoKnowledgeRetriever? = 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 tokenizer: Tokenizer? = tokenizer
|
||||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||||
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
|
||||||
|
}
|
||||||
+2
@@ -187,6 +187,7 @@ abstract class SessionOrchestrator(
|
|||||||
private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
|
private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
|
||||||
private val artifactKindRegistry: ArtifactKindRegistry? = null,
|
private val artifactKindRegistry: ArtifactKindRegistry? = null,
|
||||||
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||||
|
private val readyTaskCounter: ReadyTaskCounter? = null,
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(this::class.java)
|
private val log = LoggerFactory.getLogger(this::class.java)
|
||||||
private val eventStore: EventStore = repositories.eventStore
|
private val eventStore: EventStore = repositories.eventStore
|
||||||
@@ -1856,6 +1857,7 @@ abstract class SessionOrchestrator(
|
|||||||
currentStage = currentStageId,
|
currentStage = currentStageId,
|
||||||
artifacts = artifacts,
|
artifacts = artifacts,
|
||||||
artifactContent = artifactContent,
|
artifactContent = artifactContent,
|
||||||
|
readyTaskCount = readyTaskCounter?.count(sessionId) ?: 0,
|
||||||
)
|
)
|
||||||
return transitionResolver.resolve(graph, ctx)
|
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
|
||||||
|
}
|
||||||
+1
@@ -11,4 +11,5 @@ data class EvaluationContext(
|
|||||||
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||||
val variables: Map<String, String> = emptyMap(),
|
val variables: Map<String, String> = emptyMap(),
|
||||||
val artifactContent: Map<ArtifactId, String> = emptyMap(),
|
val artifactContent: Map<ArtifactId, String> = emptyMap(),
|
||||||
|
val readyTaskCount: Int = 0,
|
||||||
)
|
)
|
||||||
|
|||||||
+12
@@ -30,6 +30,18 @@ class TransitionConditionTest {
|
|||||||
assertTrue(AlwaysTrue.evaluate(baseContext))
|
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
|
// ArtifactPresent
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+2
@@ -10,6 +10,7 @@ import com.correx.core.transitions.conditions.ArtifactPresent
|
|||||||
import com.correx.core.transitions.conditions.ArtifactValidated
|
import com.correx.core.transitions.conditions.ArtifactValidated
|
||||||
import com.correx.core.transitions.conditions.FieldOperator
|
import com.correx.core.transitions.conditions.FieldOperator
|
||||||
import com.correx.core.transitions.conditions.Not
|
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.conditions.VariableEquals
|
||||||
import com.correx.core.transitions.graph.TransitionCondition
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ fun ConditionSpec.toCondition(): TransitionCondition = when (type) {
|
|||||||
"artifact_validated" -> buildArtifactValidated()
|
"artifact_validated" -> buildArtifactValidated()
|
||||||
"variable_equals" -> buildVariableEquals()
|
"variable_equals" -> buildVariableEquals()
|
||||||
"artifact_field_equals" -> buildArtifactFieldEquals()
|
"artifact_field_equals" -> buildArtifactFieldEquals()
|
||||||
|
"tasks_ready" -> TasksReady
|
||||||
"all_of" -> AllOf(conditions.map { it.toCondition() })
|
"all_of" -> AllOf(conditions.map { it.toCondition() })
|
||||||
"any_of" -> AnyOf(conditions.map { it.toCondition() })
|
"any_of" -> AnyOf(conditions.map { it.toCondition() })
|
||||||
"not" -> Not((condition ?: error("condition required for not")).toCondition())
|
"not" -> Not((condition ?: error("condition required for not")).toCondition())
|
||||||
|
|||||||
+19
@@ -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.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
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.events.types.TransitionId
|
||||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
|
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() =
|
private fun alwaysTrue() =
|
||||||
TransitionCondition { true }
|
TransitionCondition { true }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user