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.approvals.Tier
|
||||||
import com.correx.core.events.events.ToolRequest
|
import com.correx.core.events.events.ToolRequest
|
||||||
import com.correx.core.events.types.ProjectId
|
import com.correx.core.events.types.ProjectId
|
||||||
|
import com.correx.core.events.types.TaskNoteAuthor
|
||||||
import com.correx.core.tasks.TaskService
|
import com.correx.core.tasks.TaskService
|
||||||
import com.correx.core.tools.contract.Tool
|
import com.correx.core.tools.contract.Tool
|
||||||
import com.correx.core.tools.contract.ToolCapability
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
@@ -55,6 +56,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
|||||||
put("type", "boolean")
|
put("type", "boolean")
|
||||||
put("description", "Create even if an active task with the same title exists. Default false.")
|
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(
|
put(
|
||||||
"required",
|
"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
|
// Provenance: tie the new task to the session that spawned it so its context bundle
|
||||||
// can show the originating run.
|
// can show the originating run.
|
||||||
service.linkOriginSession(task.taskId, request)
|
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(
|
return ToolResult.Success(
|
||||||
invocationId = request.invocationId,
|
invocationId = request.invocationId,
|
||||||
output = "Created ${task.taskId.value}: ${task.state.title}",
|
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? {
|
private fun precheck(request: ToolRequest): ToolResult.Failure? {
|
||||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||||
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||||
val duplicates = if (request.boolParam("force")) {
|
if (request.boolParam("force")) {
|
||||||
emptyList()
|
if (request.stringParam("force_reason") == null) {
|
||||||
} else {
|
return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
|
||||||
service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||||
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
||||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||||
ToolResult.Failure(
|
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||||
request.invocationId,
|
|
||||||
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.",
|
|
||||||
recoverable = false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("note") { put("type", "string"); put("description", "Append an agent note.") }
|
||||||
putJsonObject("force") {
|
putJsonObject("force") {
|
||||||
put("type", "boolean")
|
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")) })
|
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||||
@@ -101,6 +105,7 @@ class TaskUpdateTool(
|
|||||||
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
|
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
|
||||||
|
|
||||||
val action = request.stringParam("action")
|
val action = request.stringParam("action")
|
||||||
|
forceReasonGate(request)?.let { return it }
|
||||||
claimBlock(request, taskId, action)?.let { return it }
|
claimBlock(request, taskId, action)?.let { return it }
|
||||||
completeBlock(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) }
|
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
|
||||||
|
|
||||||
|
recordForceNote(request, taskId, action)
|
||||||
recordWorkingTask(request, taskId, action)
|
recordWorkingTask(request, taskId, action)
|
||||||
|
|
||||||
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
|
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? {
|
private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
|
||||||
if (action != "claim" || request.boolParam("force")) return null
|
if (action != "claim" || request.boolParam("force")) return null
|
||||||
val unmet = service.blockers(taskId)
|
val unmet = service.blockers(taskId)
|
||||||
if (unmet.isEmpty()) return null
|
if (unmet.isEmpty()) return null
|
||||||
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
|
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(
|
return fail(
|
||||||
request,
|
request,
|
||||||
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
|
"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
|
@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")))
|
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")))
|
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 is ToolResult.Failure)
|
||||||
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
|
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)),
|
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)
|
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
|
@Test
|
||||||
@@ -204,10 +220,22 @@ class TaskToolsTest {
|
|||||||
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
|
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
|
||||||
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
|
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
|
||||||
|
|
||||||
// force=true claims anyway, with a warning surfaced.
|
// force without a reason is rejected.
|
||||||
val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
|
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"))
|
assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
|
||||||
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user