feat(kernel): deterministic per-task claim stage + per-task write scope

A stage flagged claimTask gets the kernel (not the LLM) to claim the
next ready task — or re-surface the one already claimed on a review
bounce — and inject its TaskContextAssembler bundle as L0. While a task
is claimed, plane-2's write manifest narrows to the task's affectedPaths.

Project identity is resolved from the origin-session link the task tools
already record (TaskService.projectForSession), so the loop predicate and
claim stage agree regardless of the free-form project key the agent passed.
Claiming flows through a kernel-declared TaskClaimCoordinator implemented
in apps/server (no cross-core import).
This commit is contained in:
2026-06-29 00:56:55 +04:00
parent c1e4c7b25e
commit 3e44c6d107
8 changed files with 159 additions and 23 deletions
@@ -368,7 +368,17 @@ fun main() {
compactionService = journalCompactionService,
artifactKindRegistry = artifactKindRegistry,
repoKnowledgeRetriever = repoKnowledgeRetriever,
readyTaskCounter = com.correx.apps.server.tasks.ProjectReadyTaskCounter(eventStore, taskService),
readyTaskCounter = com.correx.apps.server.tasks.ProjectReadyTaskCounter(taskService),
taskClaimCoordinator = com.correx.apps.server.tasks.DefaultTaskClaimCoordinator(
taskService,
com.correx.core.tasks.TaskContextAssembler(
taskService,
taskKnowledgeRetriever,
taskDocumentResolver,
taskArtifactResolver,
taskSessionResolver,
),
),
)
val workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
@@ -0,0 +1,29 @@
package com.correx.apps.server.tasks
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.TaskClaimCoordinator
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
/**
* [TaskClaimCoordinator] over the task projection: claims the session's next ready task (claimant =
* session id) and renders its [TaskContextAssembler] bundle as the implementer's L0 context. The
* claim emits a `TaskClaimedEvent`, so the task leaves `ready()` and the `tasks_ready` predicate can
* eventually terminate the loop — driven by recorded fact, not agent compliance.
*/
class DefaultTaskClaimCoordinator(
private val taskService: TaskService,
private val assembler: TaskContextAssembler,
) : TaskClaimCoordinator {
override suspend fun claimNext(sessionId: SessionId): String? {
val active = taskService.activeClaim(sessionId.value)
val task = active ?: taskService.nextReadyForSession(sessionId.value)
?.also { taskService.claim(it.taskId, sessionId.value) }
?: return null
return assembler.assemble(task.taskId)?.render()
}
override fun activeScope(sessionId: SessionId): List<String> =
taskService.activeClaim(sessionId.value)?.state?.affectedPaths ?: emptyList()
}
@@ -1,31 +1,18 @@
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.
* [ReadyTaskCounter] over the task projection. Resolves the session to the project its tasks were
* actually filed under ([TaskService.projectForSession] — via the CONTEXT/SESSION link the task
* tools record) and counts the ready tasks there, so the loop predicate agrees with the claim stage
* regardless of the free-form `project` key the agent chose. Pure read over event-derived state.
*/
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
}
override fun count(sessionId: SessionId): Int =
taskService.projectForSession(sessionId.value)?.let { taskService.ready(it).size } ?: 0
}