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:
@@ -92,6 +92,42 @@ class TaskService(
|
||||
fun ready(projectId: ProjectId? = null): List<Task> =
|
||||
TaskGraph.ready(projectId?.let { list(it) } ?: listAll())
|
||||
|
||||
/**
|
||||
* The project a session is working in, inferred from the tasks it created/touched: any task
|
||||
* carrying a CONTEXT/SESSION link back to [sessionId] (recorded by the task tools at create and
|
||||
* claim time). Robust to whatever free-form `project` key the agent passed — it reads back the
|
||||
* project the tasks were actually filed under, so the execution-loop predicate and claim stage
|
||||
* agree without trusting the agent. Picks the earliest such task's project (lowest key sequence)
|
||||
* if a session ever spans projects. Null when the session has created no tasks yet.
|
||||
*
|
||||
* ponytail: scans the whole cross-project board (listAll) per call; fine for local single-project
|
||||
* use. Index by session link if a deployment ever runs many projects in one store.
|
||||
*/
|
||||
fun projectForSession(sessionId: String): ProjectId? =
|
||||
listAll()
|
||||
.filter { t -> t.state.links.any(linkToSession(sessionId)) }
|
||||
.minByOrNull(::keySeq)
|
||||
?.state?.projectId
|
||||
|
||||
/** Next ready task for the session's project, in deterministic claim order (creation sequence, then id). */
|
||||
fun nextReadyForSession(sessionId: String): Task? =
|
||||
projectForSession(sessionId)?.let { ready(it) }.orEmpty()
|
||||
.minWithOrNull(compareBy({ keySeq(it) }, { it.taskId.value }))
|
||||
|
||||
/** The task this session currently has claimed — its in-flight unit of work — or null. */
|
||||
fun activeClaim(sessionId: String): Task? =
|
||||
projectForSession(sessionId)?.let { list(it) }.orEmpty()
|
||||
.firstOrNull { it.state.status == TaskStatus.IN_PROGRESS && it.state.claimant == sessionId }
|
||||
|
||||
private fun linkToSession(sessionId: String): (TaskLink) -> Boolean = { link ->
|
||||
link.type == TaskLinkType.CONTEXT &&
|
||||
link.targetKind == TaskTargetKind.SESSION &&
|
||||
link.targetId == sessionId
|
||||
}
|
||||
|
||||
private fun keySeq(task: Task): Int =
|
||||
task.taskId.value.substringAfterLast('-').toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
/** Unfinished tasks that must complete before [taskId] can proceed (resolved within its project). */
|
||||
fun blockers(taskId: TaskId): List<Task> {
|
||||
val task = getTask(taskId) ?: return emptyList()
|
||||
|
||||
@@ -72,6 +72,31 @@ class TaskServiceTest {
|
||||
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session resolution drives the deterministic claim loop`() = runBlocking {
|
||||
// Two tasks filed under this session (via the CONTEXT/SESSION link the tools record), one
|
||||
// unrelated. The loop must resolve the project from those links, regardless of its key.
|
||||
val session = "sess-1"
|
||||
val a = service.createTask(project, "first", "g", affectedPaths = listOf("src/a/**")).taskId
|
||||
val b = service.createTask(project, "second", "g").taskId
|
||||
service.link(a, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
|
||||
service.link(b, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
|
||||
service.createTask(ProjectId("other"), "noise", "g")
|
||||
|
||||
assertEquals(project, service.projectForSession(session))
|
||||
// Deterministic order: lowest key sequence first.
|
||||
assertEquals(a, service.nextReadyForSession(session)?.taskId)
|
||||
|
||||
// Claiming moves it off ready() so the predicate eventually terminates, and becomes the
|
||||
// session's active claim carrying its write scope.
|
||||
service.claim(a, session)
|
||||
assertEquals(b, service.nextReadyForSession(session)?.taskId)
|
||||
assertEquals(a, service.activeClaim(session)?.taskId)
|
||||
assertEquals(listOf("src/a/**"), service.activeClaim(session)?.state?.affectedPaths)
|
||||
|
||||
assertNull(service.projectForSession("unknown-session"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listAll spans every project's stream`() = runBlocking {
|
||||
service.createTask(project, "a", "g")
|
||||
|
||||
Reference in New Issue
Block a user