diff --git a/apps/web/app/api/v1/approvals/route.ts b/apps/web/app/api/v1/approvals/route.ts index 91e8824..e3bf84f 100644 --- a/apps/web/app/api/v1/approvals/route.ts +++ b/apps/web/app/api/v1/approvals/route.ts @@ -5,7 +5,10 @@ export async function GET(request: Request) { const traceId = requestTraceId(request); try { return Response.json({ - data: await new ApprovalDomainService().list(await apiSubject(request)), + data: await new ApprovalDomainService().list( + await apiSubject(request), + traceId, + ), traceId, }); } catch (error) { diff --git a/apps/web/features/approvals/governance-inbox.tsx b/apps/web/features/approvals/governance-inbox.tsx index 8d0fd92..8b2c505 100644 --- a/apps/web/features/approvals/governance-inbox.tsx +++ b/apps/web/features/approvals/governance-inbox.tsx @@ -209,7 +209,14 @@ function ApprovalDetail({ }) { const severity = riskSeverity(approval.riskSummary); const highImpact = severity === "critical"; - const pending = approval.status === "pending"; + // A row can still read as pending until the next inbox load expires it, so + // trust the deadline rather than the stored status for what is offerable. + const overdue = new Date(approval.expiresAt) <= new Date(); + const pending = approval.status === "pending" && !overdue; + // Includes rows already stored as `expired` by the inbox's lazy sweep, not + // just ones that are still nominally pending. + const closable = + approval.status === "expired" || (approval.status === "pending" && overdue); return (
- This approval is no longer pending. + {approval.status === "expired" + ? "This approval expired without a decision. Nothing was executed." + : "This approval is no longer pending."} {approval.reason ? ` Reason: ${approval.reason}` : ""}
)} diff --git a/apps/web/features/operations/operations-view.test.ts b/apps/web/features/operations/operations-view.test.ts index 9441069..aa1da75 100644 --- a/apps/web/features/operations/operations-view.test.ts +++ b/apps/web/features/operations/operations-view.test.ts @@ -29,7 +29,7 @@ describe("Operations board", () => { expect(view).toContain("dispatchBlockedReason"); // Every refusal path returns operator-readable text. expect(view).toContain("Assign this task to an agent to dispatch it."); - expect(view).toContain("already has an active agent run"); + expect(view).toContain("A run is already in flight."); expect(view).toContain("assigneeReadinessReason"); }); @@ -103,3 +103,29 @@ describe("Task composer", () => { expect(composer).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-/); }); }); + +describe("Stuck and failed agent work", () => { + it("offers cancel while a run is in flight", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("useCancelTaskRun"); + expect(view).toContain("Cancel run"); + // Every status the server treats as in-flight must be escapable, not just + // queued/running — awaiting_approval wedges a task just as hard. + expect(view).toContain('"awaiting_approval"'); + expect(view).toContain('"waiting_sources"'); + }); + + it("labels a re-dispatch as a retry and says why", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("function isRetry"); + expect(view).toContain("Retry dispatch"); + expect(view).toContain("Previous run"); + }); + + it("points a blocked dispatch at the way out", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain( + "A run is already in flight. Cancel it before dispatching again.", + ); + }); +}); diff --git a/apps/web/features/operations/operations-view.tsx b/apps/web/features/operations/operations-view.tsx index 5bdc61a..bb52b07 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 { + useCancelTaskRun, useDelegateTask, useTasks, useUpdateTask, @@ -162,12 +163,24 @@ function taskToBoardItem(task: RawTask): BoardItem { }; } -/** A run that is already queued or running must not be dispatched again. */ +/** Statuses the server treats as an in-flight run; cancel is the only exit. */ +const ACTIVE_RUN_STATUSES = [ + "queued", + "running", + "awaiting_approval", + "waiting_sources", +]; + +function hasActiveRun(item: BoardItem): boolean { + return ACTIVE_RUN_STATUSES.includes(item.agentRunStatus ?? ""); +} + +/** A run already in flight must not be dispatched again. */ function dispatchBlockedReason(item: BoardItem): string | null { if (!item.assigneeIsAgent) return "Assign this task to an agent to dispatch it."; - if (item.agentRunStatus === "queued" || item.agentRunStatus === "running") - return "This task already has an active agent run."; + if (hasActiveRun(item)) + return "A run is already in flight. Cancel it before dispatching again."; if (item.assigneeReadiness !== "ready") return ( item.assigneeReadinessReason ?? "Assigned agent is not ready for work." @@ -175,6 +188,13 @@ function dispatchBlockedReason(item: BoardItem): string | null { return null; } +/** A settled run can be handed back to the agent; label it as a retry. */ +function isRetry(item: BoardItem): boolean { + return ( + item.agentRunStatus === "failed" || item.agentRunStatus === "cancelled" + ); +} + export function OperationsView() { const tasks = useTasks(); const updateTask = useUpdateTask(); @@ -473,6 +493,7 @@ export function OperationsView() { function DetailDrawer({ item }: { item: BoardItem | null }) { const delegateTask = useDelegateTask(); + const cancelRun = useCancelTaskRun(); const [error, setError] = useState