feat(toolintent): record the session's active task as a session-local fact
Claiming a task now emits SessionWorkingTaskEvent{taskId, affectedPaths} into the
session's own stream (via a SessionFactRecorder port + event-store adapter), and
SessionContextProjection folds it into SessionContext.activeTask. So a gate learns
"which task is this session working, and its scope" by reading the session stream
— no cross-stream scan, no kernel->tasks dependency. Re-emitted on an affected_paths
edit by the claimant, so the snapshot scope stays current; latest wins on replay.
This is the data source the write-scope gate needs (next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.TaskId
|
||||
|
||||
/**
|
||||
* Port: record that a session is working a task — emitted on claim (and on an affected_paths edit by
|
||||
* the claimant) so the gates can fold it into SessionContext.activeTask without scanning task
|
||||
* streams. The host supplies an adapter that appends a SessionWorkingTaskEvent to the session
|
||||
* stream. A null binding means no recording (the bare tool still works).
|
||||
*/
|
||||
fun interface SessionFactRecorder {
|
||||
suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List<String>)
|
||||
}
|
||||
+2
-1
@@ -21,10 +21,11 @@ object TaskTools {
|
||||
documentResolver: TaskDocumentResolver? = null,
|
||||
artifactResolver: TaskArtifactResolver? = null,
|
||||
sessionResolver: TaskSessionResolver? = null,
|
||||
sessionFacts: SessionFactRecorder? = null,
|
||||
): List<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskUpdateTool(service),
|
||||
TaskUpdateTool(service, sessionFacts),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
TaskReadyTool(service),
|
||||
|
||||
+22
-1
@@ -25,7 +25,10 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph
|
||||
* link, or attach a note. The task's project is derived from its id, so only the id is required.
|
||||
*/
|
||||
class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
class TaskUpdateTool(
|
||||
private val service: TaskService,
|
||||
private val sessionFacts: SessionFactRecorder? = null,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_update"
|
||||
override val description: String =
|
||||
@@ -110,6 +113,8 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
|
||||
|
||||
recordWorkingTask(request, taskId, action)
|
||||
|
||||
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
|
||||
val warning = if (action == "claim") claimWarning(taskId) else ""
|
||||
return ToolResult.Success(
|
||||
@@ -128,6 +133,22 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on
|
||||
* an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without
|
||||
* a recorder bound.
|
||||
*/
|
||||
private suspend fun recordWorkingTask(request: ToolRequest, taskId: TaskId, action: String?) {
|
||||
val recorder = sessionFacts ?: return
|
||||
val task = service.getTask(taskId) ?: return
|
||||
val isClaim = action == "claim"
|
||||
val scopeEditByClaimant = request.parameters.containsKey("affected_paths") &&
|
||||
task.state.claimant == request.sessionId.value
|
||||
if (isClaim || scopeEditByClaimant) {
|
||||
recorder.recordWorkingTask(request.sessionId, taskId, task.state.affectedPaths)
|
||||
}
|
||||
}
|
||||
|
||||
/** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */
|
||||
private fun claimWarning(taskId: TaskId): String {
|
||||
val blockers = service.blockers(taskId)
|
||||
|
||||
+18
@@ -210,6 +210,24 @@ class TaskToolsTest {
|
||||
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claiming a task records the session working it`() = runBlocking {
|
||||
create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
|
||||
),
|
||||
)
|
||||
val captured = mutableListOf<Triple<String, String, List<String>>>()
|
||||
val recorder = SessionFactRecorder { sid, tid, paths -> captured.add(Triple(sid.value, tid.value, paths)) }
|
||||
|
||||
TaskUpdateTool(service, recorder).execute(request("task_update", mapOf("id" to "auth-1", "action" to "claim")))
|
||||
|
||||
assertEquals(1, captured.size)
|
||||
assertEquals("auth-1", captured.single().second)
|
||||
assertEquals(listOf("core/auth/**"), captured.single().third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_delete tombstones the task`() = runBlocking {
|
||||
val id = createTask()
|
||||
|
||||
Reference in New Issue
Block a user