feat(tasks): force overrides require a recorded reason
Bypassing a gate with force=true now requires a force_reason, recorded as a "[force] ..." note on the task so the override is auditable (visible in history and the context bundle) rather than a silent escape hatch. Applies to the agent tools: task_create (dedup override) and task_update (claim over unmet blockers, complete with no in-scope writes). force without a reason is rejected. The REST POST /tasks create override (operator/CLI path) still takes a bare force and is left for a separate call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+25
-10
@@ -3,6 +3,7 @@ package com.correx.infrastructure.tools.task
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
@@ -55,6 +56,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
put("type", "boolean")
|
||||
put("description", "Create even if an active task with the same title exists. Default false.")
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why a duplicate is acceptable. Recorded on the task.")
|
||||
}
|
||||
}
|
||||
put(
|
||||
"required",
|
||||
@@ -88,6 +93,12 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
// 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)
|
||||
// A forced creation bypassed the duplicate guard — record why, for the audit trail.
|
||||
if (request.boolParam("force")) {
|
||||
request.stringParam("force_reason")?.let {
|
||||
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "[force] created despite the duplicate guard: $it")
|
||||
}
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "Created ${task.taskId.value}: ${task.state.title}",
|
||||
@@ -95,22 +106,26 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
)
|
||||
}
|
||||
|
||||
/** Required-field validation, then a duplicate-title guard (unless force) — null when OK to create. */
|
||||
/**
|
||||
* Required-field validation; then either a force-reason requirement (force=true must carry a
|
||||
* force_reason, recorded after creation) or the duplicate-title guard. Null when OK to create.
|
||||
*/
|
||||
private fun precheck(request: ToolRequest): ToolResult.Failure? {
|
||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||
val duplicates = if (request.boolParam("force")) {
|
||||
emptyList()
|
||||
} else {
|
||||
service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
if (request.boolParam("force")) {
|
||||
if (request.stringParam("force_reason") == null) {
|
||||
return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
|
||||
}
|
||||
return null
|
||||
}
|
||||
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.",
|
||||
recoverable = false,
|
||||
)
|
||||
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
}
|
||||
|
||||
+25
-4
@@ -81,7 +81,11 @@ class TaskUpdateTool(
|
||||
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Claim even if the task has unmet blockers. Default false.")
|
||||
put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
@@ -101,6 +105,7 @@ class TaskUpdateTool(
|
||||
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
|
||||
|
||||
val action = request.stringParam("action")
|
||||
forceReasonGate(request)?.let { return it }
|
||||
claimBlock(request, taskId, action)?.let { return it }
|
||||
completeBlock(request, taskId, action)?.let { return it }
|
||||
|
||||
@@ -121,6 +126,7 @@ class TaskUpdateTool(
|
||||
|
||||
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
|
||||
|
||||
recordForceNote(request, taskId, action)
|
||||
recordWorkingTask(request, taskId, action)
|
||||
|
||||
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
|
||||
@@ -132,13 +138,28 @@ class TaskUpdateTool(
|
||||
)
|
||||
}
|
||||
|
||||
/** Refuse to claim a task with unmet blockers (escapable with force) — fail before any mutation. */
|
||||
/** A force override must carry a recorded justification — reject force=true with no force_reason. */
|
||||
private fun forceReasonGate(request: ToolRequest): ToolResult.Failure? =
|
||||
if (request.boolParam("force") && request.stringParam("force_reason") == null) {
|
||||
fail(request, "force=true requires 'force_reason' explaining the override.")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/** A forced claim/complete bypassed a gate — record why on the task, for the audit trail. */
|
||||
private suspend fun recordForceNote(request: ToolRequest, taskId: TaskId, action: String?) {
|
||||
if (!request.boolParam("force")) return
|
||||
val reason = request.stringParam("force_reason") ?: return
|
||||
service.addNote(taskId, TaskNoteAuthor.AGENT, "[force] ${action ?: "update"}: $reason")
|
||||
}
|
||||
|
||||
/** Refuse to claim a task with unmet blockers (escapable with force+reason) — fail before any mutation. */
|
||||
private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
|
||||
if (action != "claim" || request.boolParam("force")) return null
|
||||
val unmet = service.blockers(taskId)
|
||||
if (unmet.isEmpty()) return null
|
||||
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
|
||||
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 force=true with force_reason.")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +176,7 @@ class TaskUpdateTool(
|
||||
return fail(
|
||||
request,
|
||||
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
|
||||
"session. Make the change, or pass force=true.",
|
||||
"session. Make the change, or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+32
-4
@@ -174,17 +174,33 @@ class TaskToolsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create blocks a duplicate title and force overrides`() = runBlocking {
|
||||
fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))
|
||||
|
||||
val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")))
|
||||
assertTrue(dup is ToolResult.Failure)
|
||||
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
|
||||
|
||||
val forced = create.execute(
|
||||
// force without a reason is rejected.
|
||||
val noReason = create.execute(
|
||||
request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)),
|
||||
)
|
||||
assertTrue(noReason is ToolResult.Failure)
|
||||
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
|
||||
|
||||
// force with a reason creates, and records the reason as a note.
|
||||
val forced = create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g",
|
||||
"force" to true, "force_reason" to "separate refresh path for mobile"),
|
||||
),
|
||||
)
|
||||
assertTrue(forced is ToolResult.Success)
|
||||
val id = (forced as ToolResult.Success).metadata.getValue("taskId")
|
||||
val note = service.getTask(TaskId(id))!!.state.notes.single()
|
||||
assertTrue(note.body.contains("[force]"))
|
||||
assertTrue(note.body.contains("separate refresh path for mobile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,10 +220,22 @@ class TaskToolsTest {
|
||||
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
|
||||
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
|
||||
|
||||
// force=true claims anyway, with a warning surfaced.
|
||||
val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
|
||||
// force without a reason is rejected.
|
||||
val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
|
||||
assertTrue(noReason is ToolResult.Failure)
|
||||
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
|
||||
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)
|
||||
|
||||
// force with a reason claims, records the reason as a note, and warns.
|
||||
val forced = update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"),
|
||||
),
|
||||
)
|
||||
assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
|
||||
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
|
||||
assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user