feat(tasks): auto-link the originating agent session

When an agent creates or claims a task via the tool, record its session as
a CONTEXT/SESSION link so the context bundle's session resolver has real
edges to inline (status/intent/workflow). Shared linkOriginSession helper;
reducer dedupes so create+claim from one session is a single edge. The REST
create path stays unlinked (no agent session there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:15:54 +00:00
parent 6b00d89c2c
commit 70b4357316
4 changed files with 44 additions and 2 deletions
@@ -80,6 +80,9 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
acceptanceCriteria = request.listParam("acceptance_criteria"), acceptanceCriteria = request.listParam("acceptance_criteria"),
affectedPaths = request.listParam("affected_paths"), affectedPaths = request.listParam("affected_paths"),
) )
// Provenance: tie the new task to the session that spawned it so its context bundle
// can show the originating run.
service.linkOriginSession(task.taskId, request)
return ToolResult.Success( return ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = "Created ${task.taskId.value}: ${task.state.title}", output = "Created ${task.taskId.value}: ${task.state.title}",
@@ -127,7 +127,12 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
val claimant = request.stringParam("claimant") ?: request.sessionId.value val claimant = request.stringParam("claimant") ?: request.sessionId.value
val reason = request.stringParam("reason") val reason = request.stringParam("reason")
when (action) { when (action) {
"claim" -> service.claim(taskId, claimant) "claim" -> {
service.claim(taskId, claimant)
// Record the claiming session too, so a task picked up by a different run than
// the one that created it links both into its work graph.
service.linkOriginSession(taskId, request)
}
"release" -> service.release(taskId) "release" -> service.release(taskId)
"block" -> service.block(taskId, reason ?: "blocked") "block" -> service.block(taskId, reason ?: "blocked")
"unblock" -> service.unblock(taskId) "unblock" -> service.unblock(taskId)
@@ -1,6 +1,10 @@
package com.correx.infrastructure.tools.task package com.correx.infrastructure.tools.task
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.TaskService
/** A non-blank string parameter, or null when absent/blank/not a string. */ /** A non-blank string parameter, or null when absent/blank/not a string. */
internal fun ToolRequest.stringParam(name: String): String? = internal fun ToolRequest.stringParam(name: String): String? =
@@ -9,3 +13,14 @@ internal fun ToolRequest.stringParam(name: String): String? =
/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */ /** A string-list parameter (JSON array), coerced element-wise; empty when absent. */
internal fun ToolRequest.listParam(name: String): List<String> = internal fun ToolRequest.listParam(name: String): List<String> =
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
/**
* Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can
* surface the run(s) that created or worked on it (the session resolver inlines status/intent).
* No-op for a blank session id; duplicate links are deduped by the reducer, so creating and then
* claiming from the same session yields a single edge.
*/
internal suspend fun TaskService.linkOriginSession(taskId: TaskId, request: ToolRequest) {
val session = request.sessionId.value.takeIf { it.isNotBlank() } ?: return
link(taskId, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
}
@@ -6,6 +6,9 @@ import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
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.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.events.types.ToolInvocationId import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
@@ -82,10 +85,26 @@ class TaskToolsTest {
assertEquals(TaskStatus.IN_PROGRESS, state.status) assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant) assertEquals("claude-opus", state.claimant)
assertEquals("JWT refresh flow", state.title) assertEquals("JWT refresh flow", state.title)
assertEquals("adr-7", state.links.single().targetId) assertTrue(state.links.any { it.targetId == "adr-7" && it.type == TaskLinkType.IMPLEMENTS })
assertEquals("starting", state.notes.single().body) assertEquals("starting", state.notes.single().body)
} }
@Test
fun `task_create and claim auto-link the agent session as CONTEXT`() = runBlocking {
val id = createTask() // session s-1
val taskId = TaskId(id)
// Created from s-1 → one CONTEXT/SESSION edge.
val afterCreate = service.getTask(taskId)!!.state.links.single()
assertEquals("s-1", afterCreate.targetId)
assertEquals(TaskTargetKind.SESSION, afterCreate.targetKind)
assertEquals(TaskLinkType.CONTEXT, afterCreate.type)
// Claiming from the same session dedupes to the same single edge.
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
assertEquals(1, service.getTask(taskId)!!.state.links.count { it.targetId == "s-1" })
}
@Test @Test
fun `task_update rejects an unknown action`() = runBlocking { fun `task_update rejects an unknown action`() = runBlocking {
val id = createTask() val id = createTask()