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:
@@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule
|
|||||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||||
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
||||||
import com.correx.core.toolintent.rules.ReferenceExistsRule
|
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.freestyle.FreestyleDriver
|
||||||
import com.correx.apps.server.inference.summarizeWithInference
|
import com.correx.apps.server.inference.summarizeWithInference
|
||||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
import com.correx.core.kernel.orchestration.JournalCompactionService
|
||||||
@@ -239,6 +240,7 @@ fun main() {
|
|||||||
taskArtifactResolver,
|
taskArtifactResolver,
|
||||||
taskSessionResolver,
|
taskSessionResolver,
|
||||||
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
|
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
|
||||||
|
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
|
||||||
)
|
)
|
||||||
val toolRegistry = InfrastructureModule.createToolRegistry(
|
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||||
buildToolConfig(
|
buildToolConfig(
|
||||||
@@ -266,6 +268,7 @@ fun main() {
|
|||||||
PathContainmentRule(),
|
PathContainmentRule(),
|
||||||
ReadBeforeWriteRule(),
|
ReadBeforeWriteRule(),
|
||||||
ReferenceExistsRule(),
|
ReferenceExistsRule(),
|
||||||
|
WriteScopeRule(),
|
||||||
ManifestContainmentRule(),
|
ManifestContainmentRule(),
|
||||||
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
||||||
NetworkHostRule(
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,
|
artifactResolver: TaskArtifactResolver? = null,
|
||||||
sessionResolver: TaskSessionResolver? = null,
|
sessionResolver: TaskSessionResolver? = null,
|
||||||
sessionFacts: SessionFactRecorder? = null,
|
sessionFacts: SessionFactRecorder? = null,
|
||||||
|
sessionWrites: SessionWrites? = null,
|
||||||
): List<Tool> =
|
): List<Tool> =
|
||||||
listOf(
|
listOf(
|
||||||
TaskCreateTool(service),
|
TaskCreateTool(service),
|
||||||
TaskUpdateTool(service, sessionFacts),
|
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||||
TaskDeleteTool(service),
|
TaskDeleteTool(service),
|
||||||
TaskSearchTool(service),
|
TaskSearchTool(service),
|
||||||
TaskReadyTool(service),
|
TaskReadyTool(service),
|
||||||
|
|||||||
+37
@@ -20,14 +20,21 @@ import kotlinx.serialization.json.buildJsonArray
|
|||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
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
|
* 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.
|
* 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(
|
class TaskUpdateTool(
|
||||||
private val service: TaskService,
|
private val service: TaskService,
|
||||||
private val sessionFacts: SessionFactRecorder? = null,
|
private val sessionFacts: SessionFactRecorder? = null,
|
||||||
|
private val sessionWrites: SessionWrites? = null,
|
||||||
) : Tool, ToolExecutor {
|
) : Tool, ToolExecutor {
|
||||||
|
|
||||||
override val name: String = "task_update"
|
override val name: String = "task_update"
|
||||||
@@ -95,6 +102,7 @@ class TaskUpdateTool(
|
|||||||
|
|
||||||
val action = request.stringParam("action")
|
val action = request.stringParam("action")
|
||||||
claimBlock(request, taskId, action)?.let { return it }
|
claimBlock(request, taskId, action)?.let { return it }
|
||||||
|
completeBlock(request, taskId, action)?.let { return it }
|
||||||
|
|
||||||
applyFieldEdits(request, taskId)
|
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.")
|
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
|
* 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
|
* 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)
|
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
|
@Test
|
||||||
fun `task_delete tombstones the task`() = runBlocking {
|
fun `task_delete tombstones the task`() = runBlocking {
|
||||||
val id = createTask()
|
val id = createTask()
|
||||||
|
|||||||
Reference in New Issue
Block a user