From 0cc31fada79d3b9bcdc8ca4384de8e83502c4a11 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 16:15:06 +1000 Subject: [PATCH] fix(web): archive tasks from the board and release provably dead runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a work item became permanently stuck. There was no delete or archive path at all — not in the UI, not in the API. 'archivedAt' existed and every list already filtered on it, but nothing could set it, so the only way to clear a task was a manual database update. - PATCH /api/v1/tasks/[id] accepts { archived }, audited as task.archived / task.restored. - Soft delete, never a row delete, so audit events that reference the task keep their target. - Archiving is refused while a run is queued or running: hiding a task whose agent is still burning budget and may still write evidence is worse than leaving it visible. Cancel first. Cancel itself could wedge. It called the gateway and threw on any failure, so a run the gateway had lost — dead worker, expired lease — could never be released, leaving the task undeletable and undispatchable at once. Cancel now force-releases a run only when it provably cannot still be executing: both the lease and the deadline have passed, or the run row is gone. Anything else still refuses with 502 rather than reporting a run cancelled while the gateway may still be running it. A forced release records that the gateway did not confirm, and why. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/app/api/v1/tasks/[id]/cancel/route.ts | 78 +++++++++++++++---- apps/web/app/api/v1/tasks/[id]/route.ts | 21 ++++- .../operations/operations-view.test.ts | 42 ++++++++++ .../features/operations/operations-view.tsx | 32 ++++++++ apps/web/lib/queries/hooks.ts | 23 ++++++ apps/web/lib/task-domain.ts | 62 +++++++++++++++ 6 files changed, 240 insertions(+), 18 deletions(-) diff --git a/apps/web/app/api/v1/tasks/[id]/cancel/route.ts b/apps/web/app/api/v1/tasks/[id]/cancel/route.ts index 9d63979..49cedf7 100644 --- a/apps/web/app/api/v1/tasks/[id]/cancel/route.ts +++ b/apps/web/app/api/v1/tasks/[id]/cancel/route.ts @@ -47,20 +47,61 @@ export async function POST( "Task does not have an active agent run.", ); } - const gateway = await fetch( - `${process.env.AGENT_GATEWAY_URL ?? "http://agent-gateway:3002"}/v1/runs/${encodeURIComponent(task.agentRunId)}/cancel`, - { - headers: agentGatewayHeaders(subject.organisationId), - method: "POST", - signal: AbortSignal.timeout(5_000), - }, - ); - const result = (await gateway.json()) as { - status?: string; - error?: string; - }; - if (!gateway.ok) - throw new Error(result.error ?? "Agent cancellation failed"); + // A run whose lease and deadline have both passed cannot still be + // executing, so the gateway's opinion is not required to release it. + // Without this a wedged run — worker died, lease expired, gateway lost + // the record — leaves the task permanently undeletable and undispatchable. + const [run] = await database() + .select({ + leaseExpiresAt: schema.agentRuns.leaseExpiresAt, + deadlineAt: schema.agentRuns.deadlineAt, + }) + .from(schema.agentRuns) + .where( + and( + eq(schema.agentRuns.id, task.agentRunId), + eq(schema.agentRuns.organisationId, subject.organisationId), + ), + ) + .limit(1); + const now = Date.now(); + const stale = + !run || + ((run.leaseExpiresAt === null || run.leaseExpiresAt.getTime() < now) && + (run.deadlineAt === null || run.deadlineAt.getTime() < now)); + + let result: { status?: string; error?: string } = {}; + let gatewayConfirmed = false; + let gatewayError: string | null = null; + try { + const gateway = await fetch( + `${process.env.AGENT_GATEWAY_URL ?? "http://agent-gateway:3002"}/v1/runs/${encodeURIComponent(task.agentRunId)}/cancel`, + { + headers: agentGatewayHeaders(subject.organisationId), + method: "POST", + signal: AbortSignal.timeout(5_000), + }, + ); + result = (await gateway.json().catch(() => ({}))) as typeof result; + gatewayConfirmed = gateway.ok; + if (!gateway.ok) + gatewayError = result.error ?? `Agent gateway returned ${gateway.status}`; + } catch (cause) { + gatewayError = + cause instanceof Error ? cause.message : "Agent gateway is unreachable"; + } + + // Only force the release when the run provably cannot still be alive. + // Otherwise refuse, so Muster never reports a run cancelled while the + // gateway is still executing it. + if (!gatewayConfirmed && !stale) { + throw new ApiProblem( + 502, + "Cancellation not confirmed", + `The agent gateway did not confirm cancellation and this run may still be executing. ${gatewayError ?? ""}`.trim(), + ); + } + await settleAgentRun( { organisationId: subject.organisationId, @@ -71,10 +112,15 @@ export async function POST( task.agentRunId, { status: "cancelled", - error: "Cancelled by operator", + error: gatewayConfirmed + ? "Cancelled by operator" + : `Force-released by operator; agent gateway did not confirm (${gatewayError ?? "no response"}). Lease and deadline had already passed.`, }, ); - return Response.json({ data: result, traceId }, { status: 202 }); + return Response.json( + { data: { ...result, gatewayConfirmed, forced: !gatewayConfirmed }, traceId }, + { status: 202 }, + ); } catch (error) { return problemResponse(error, traceId); } diff --git a/apps/web/app/api/v1/tasks/[id]/route.ts b/apps/web/app/api/v1/tasks/[id]/route.ts index 9acd02a..9cfb075 100644 --- a/apps/web/app/api/v1/tasks/[id]/route.ts +++ b/apps/web/app/api/v1/tasks/[id]/route.ts @@ -2,7 +2,7 @@ import { requireCapability } from "@muster/authz"; import { TaskPrioritySchema, TaskStatusSchema } from "@muster/contracts"; import { z } from "zod"; import { apiSubject, problemResponse, requestTraceId } from "@/lib/api-context"; -import { updateTask, type TaskChanges } from "@/lib/task-domain"; +import { archiveTask, updateTask, type TaskChanges } from "@/lib/task-domain"; const UpdateTaskSchema = z .object({ @@ -16,6 +16,8 @@ const UpdateTaskSchema = z relatedCaseId: z.string().trim().max(160).nullable().optional(), approvalRequired: z.boolean().optional(), dueAt: z.iso.datetime({ offset: true }).nullable().optional(), + /** Soft delete. Rows stay for audit correspondence. */ + archived: z.boolean().optional(), }) .refine((value) => Object.keys(value).length > 0, "No task changes supplied"); @@ -31,7 +33,22 @@ export async function PATCH( const input = UpdateTaskSchema.parse(await request.json()); if (input.assignedActorId !== undefined) requireCapability(subject, "tasks.assign"); - const { dueAt, ...unfilteredChanges } = input; + const { dueAt, archived, ...unfilteredChanges } = input; + if (archived !== undefined) { + const result = await archiveTask( + { + organisationId: subject.organisationId, + actorId: subject.actorId, + traceId, + }, + id, + archived, + ); + // Archive is its own operation; it does not combine with field edits. + if (Object.keys(unfilteredChanges).length === 0 && dueAt === undefined) { + return Response.json({ data: result, traceId }); + } + } const changes = Object.fromEntries( Object.entries(unfilteredChanges).filter( ([, value]) => value !== undefined, diff --git a/apps/web/features/operations/operations-view.test.ts b/apps/web/features/operations/operations-view.test.ts index aa1da75..171aabb 100644 --- a/apps/web/features/operations/operations-view.test.ts +++ b/apps/web/features/operations/operations-view.test.ts @@ -129,3 +129,45 @@ describe("Stuck and failed agent work", () => { ); }); }); + +describe("Removing work from the board", () => { + it("archives rather than deletes, so audit correspondence survives", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("useArchiveTask"); + expect(view).toContain("Archive"); + expect(view).toContain("audit trail"); + }); + + it("refuses to archive a task with a live run", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("Cancel the active run before archiving"); + const domain = await readFile( + new URL("../../lib/task-domain.ts", import.meta.url), + "utf8", + ); + expect(domain).toContain("Cancel the active agent run before archiving"); + }); +}); + +describe("Force-releasing a wedged run", () => { + it("only forces when the run provably cannot still be executing", async () => { + const route = await readFile( + new URL("../../app/api/v1/tasks/[id]/cancel/route.ts", import.meta.url), + "utf8", + ); + // Both clocks must have passed; a live run must never be reported cancelled. + expect(route).toContain("leaseExpiresAt"); + expect(route).toContain("deadlineAt"); + expect(route).toContain("if (!gatewayConfirmed && !stale)"); + expect(route).toContain("may still be executing"); + }); + + it("records that the gateway did not confirm", async () => { + const route = await readFile( + new URL("../../app/api/v1/tasks/[id]/cancel/route.ts", import.meta.url), + "utf8", + ); + expect(route).toContain("Force-released by operator"); + expect(route).toContain("gatewayConfirmed"); + }); +}); diff --git a/apps/web/features/operations/operations-view.tsx b/apps/web/features/operations/operations-view.tsx index 8d9cd25..60ed713 100644 --- a/apps/web/features/operations/operations-view.tsx +++ b/apps/web/features/operations/operations-view.tsx @@ -26,6 +26,7 @@ import { type ComposerSeed, } from "@/features/operations/task-composer"; import { + useArchiveTask, useCancelTaskRun, useDelegateTask, useTasks, @@ -494,6 +495,7 @@ export function OperationsView() { function DetailDrawer({ item }: { item: BoardItem | null }) { const delegateTask = useDelegateTask(); const cancelRun = useCancelTaskRun(); + const archiveTask = useArchiveTask(); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -536,6 +538,22 @@ function DetailDrawer({ item }: { item: BoardItem | null }) { } } + async function archive() { + if (!item) return; + setError(null); + setNotice(null); + try { + await archiveTask.mutateAsync({ id: item.id, archived: true }); + setNotice("Archived. The row is kept so its audit trail stays intact."); + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Could not archive the task.", + ); + } + } + return (