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 (