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"),
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(
invocationId = request.invocationId,
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 reason = request.stringParam("reason")
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)
"block" -> service.block(taskId, reason ?: "blocked")
"unblock" -> service.unblock(taskId)
@@ -1,6 +1,10 @@
package com.correx.infrastructure.tools.task
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. */
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. */
internal fun ToolRequest.listParam(name: String): List<String> =
(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.types.SessionId
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.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
@@ -82,10 +85,26 @@ class TaskToolsTest {
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
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)
}
@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
fun `task_update rejects an unknown action`() = runBlocking {
val id = createTask()