feat(tasks): cite-before-claim gate on completion
Completing a task that declared affected_paths but saw no matching write this
session is now blocked ("marked done without doing it"), escapable with force.
TaskUpdateTool reads the session's writes through a SessionWrites port whose
adapter folds SessionContext (writes = recorded receipt.affectedEntities), and
matches them against the task's affected_paths globs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Port: the paths a session has written this run. The host supplies an adapter that folds the
|
||||
* session's SessionContext (its writes come from the recorded receipt.affectedEntities). The
|
||||
* cite-before-claim gate in [TaskUpdateTool] uses it to confirm a task being completed actually saw
|
||||
* changes under its affected_paths. A null binding leaves the gate off.
|
||||
*/
|
||||
fun interface SessionWrites {
|
||||
fun pathsWritten(sessionId: SessionId): Set<String>
|
||||
}
|
||||
+2
-1
@@ -22,10 +22,11 @@ object TaskTools {
|
||||
artifactResolver: TaskArtifactResolver? = null,
|
||||
sessionResolver: TaskSessionResolver? = null,
|
||||
sessionFacts: SessionFactRecorder? = null,
|
||||
sessionWrites: SessionWrites? = null,
|
||||
): List<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskUpdateTool(service, sessionFacts),
|
||||
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
TaskReadyTool(service),
|
||||
|
||||
+37
@@ -20,14 +20,21 @@ import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* [sessionFacts] records the session's active task on claim; [sessionWrites], when bound, powers
|
||||
* the cite-before-claim gate (completing a task that declared affected_paths but saw no matching
|
||||
* write this session is blocked, escapable with force).
|
||||
*/
|
||||
class TaskUpdateTool(
|
||||
private val service: TaskService,
|
||||
private val sessionFacts: SessionFactRecorder? = null,
|
||||
private val sessionWrites: SessionWrites? = null,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_update"
|
||||
@@ -95,6 +102,7 @@ class TaskUpdateTool(
|
||||
|
||||
val action = request.stringParam("action")
|
||||
claimBlock(request, taskId, action)?.let { return it }
|
||||
completeBlock(request, taskId, action)?.let { return it }
|
||||
|
||||
applyFieldEdits(request, taskId)
|
||||
|
||||
@@ -133,6 +141,35 @@ class TaskUpdateTool(
|
||||
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cite-before-claim: refuse to complete a task that declared affected_paths but saw no matching
|
||||
* write this session — "marked done without doing it." Off when no [sessionWrites] is bound or
|
||||
* the task declared no scope; escapable with force.
|
||||
*/
|
||||
private fun completeBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
|
||||
if (action != "complete" || request.boolParam("force")) return null
|
||||
val writes = sessionWrites ?: return null
|
||||
val scope = service.getTask(taskId)?.state?.affectedPaths.orEmpty()
|
||||
if (scope.isEmpty()) return null
|
||||
if (matchesAnyGlob(writes.pathsWritten(request.sessionId), scope)) return null
|
||||
return fail(
|
||||
request,
|
||||
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
|
||||
"session. Make the change, or pass force=true.",
|
||||
)
|
||||
}
|
||||
|
||||
/** True if any written path matches any affected_paths glob (workspace-relative; './' tolerated). */
|
||||
private fun matchesAnyGlob(written: Set<String>, globs: List<String>): Boolean {
|
||||
if (written.isEmpty()) return false
|
||||
val fs = FileSystems.getDefault()
|
||||
val matchers = globs.map { fs.getPathMatcher("glob:${it.removePrefix("./")}") }
|
||||
return written.any { w ->
|
||||
val rel = Path.of(w.removePrefix("./"))
|
||||
matchers.any { it.matches(rel) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+25
@@ -228,6 +228,31 @@ class TaskToolsTest {
|
||||
assertEquals(listOf("core/auth/**"), captured.single().third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking {
|
||||
create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
|
||||
),
|
||||
)
|
||||
val id = "auth-1"
|
||||
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
|
||||
update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review")))
|
||||
|
||||
// No matching write this session → complete is blocked, task stays IN_REVIEW.
|
||||
val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() })
|
||||
val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
|
||||
assertTrue(blocked is ToolResult.Failure)
|
||||
assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status)
|
||||
|
||||
// A write under affected_paths → completes.
|
||||
val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") })
|
||||
val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
|
||||
assertTrue(ok is ToolResult.Success)
|
||||
assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_delete tombstones the task`() = runBlocking {
|
||||
val id = createTask()
|
||||
|
||||
Reference in New Issue
Block a user