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:
2026-06-24 21:31:08 +00:00
parent 30321e08a5
commit 6b5a758081
6 changed files with 101 additions and 1 deletions
@@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService
@@ -239,6 +240,7 @@ fun main() {
taskArtifactResolver,
taskSessionResolver,
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
)
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
@@ -266,6 +268,7 @@ fun main() {
PathContainmentRule(),
ReadBeforeWriteRule(),
ReferenceExistsRule(),
WriteScopeRule(),
ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule(
@@ -0,0 +1,21 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.toolintent.SessionContextProjection
import com.correx.infrastructure.tools.task.SessionWrites
/**
* Adapter for the [SessionWrites] port: folds a session's events through
* [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities).
* Powers the cite-before-claim gate. Replay-safe (reads the log, never live state).
*/
class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites {
override fun pathsWritten(sessionId: SessionId): Set<String> {
val projection = SessionContextProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
.writes
}
}
@@ -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>
}
@@ -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),
@@ -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
@@ -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()