diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index c98e8d7c..d1f5b90c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -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}", diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 80bdcb9c..7b0607b2 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -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) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt index c8968a9c..5f816711 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -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 = (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) +} diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 888afb86..2bd5b10f 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -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()