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
@@ -62,7 +62,8 @@ class DefaultSessionOrchestrator(
artifactKindRegistry: ArtifactKindRegistry? = null,
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
readyTaskCounter: ReadyTaskCounter? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter), ApprovalGateway {
taskClaimCoordinator: TaskClaimCoordinator? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -188,6 +188,7 @@ abstract class SessionOrchestrator(
private val artifactKindRegistry: ArtifactKindRegistry? = null,
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
private val readyTaskCounter: ReadyTaskCounter? = null,
private val taskClaimCoordinator: TaskClaimCoordinator? = null,
) {
private val log = LoggerFactory.getLogger(this::class.java)
private val eventStore: EventStore = repositories.eventStore
@@ -423,9 +424,29 @@ abstract class SessionOrchestrator(
val vocabularyEntries = artifactKindRegistry
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
// Deterministic claim: a stage flagged claimTask gets the kernel to claim the next ready task
// (or re-surface the one already claimed) and inject its context bundle as L0 — the loop
// advances by recorded fact, not by the agent remembering to claim.
val claimedTaskEntries = if (stageConfig.metadata["claimTask"] == "true") {
taskClaimCoordinator?.claimNext(sessionId)?.let { bundle ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = bundle,
sourceType = "claimedTask",
sourceId = stageId.value,
tokenEstimate = estimateTokens(bundle),
role = EntryRole.SYSTEM,
),
)
} ?: emptyList()
} else {
emptyList()
}
var accumulatedEntries =
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries +
journalEntries + repoMapEntries + claimedTaskEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries
val contextPack = contextPackBuilder.build(
@@ -708,9 +729,14 @@ abstract class SessionOrchestrator(
),
)
}
// Per-task write scope: while a task is claimed, narrow the manifest to its affected
// paths (recorded on the task) so the implementer can't write outside its unit of work.
// Falls back to the stage's static manifest when nothing is claimed.
val effectiveManifest = taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() }
?: stageConfig.writeManifest
val plane2Risk: RiskSummary? = runPlane2Assessment(
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
stageConfig.writeManifest,
effectiveManifest,
)?.let { assessment ->
if (assessment.recommendedAction == RiskAction.BLOCK) {
emit(
@@ -0,0 +1,22 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.types.SessionId
/**
* Deterministic, kernel-driven task claiming for the execution loop: the kernel — not the LLM —
* picks and claims the next ready task, so the loop terminates by event-recorded fact rather than by
* agent compliance (the failure mode the read-before-write logs exposed). Implemented in apps/server
* over the task projection; core:kernel stays decoupled from core:tasks (mirrors [ReadyTaskCounter]).
* Null injection ⇒ no claiming and unrestricted task scope.
*/
interface TaskClaimCoordinator {
/**
* Ensure the session has a claimed task and return that task's context bundle as L0 text. If the
* session already has one claimed (e.g. a review bounced it back), re-injects its context without
* re-claiming; otherwise claims the next ready task. Null when nothing is ready to work.
*/
suspend fun claimNext(sessionId: SessionId): String?
/** Write-scope (affected paths) of the task the session currently has claimed; empty when none. */
fun activeScope(sessionId: SessionId): List<String>
}