From 8846f6959d1fcebba8806e5e126543f580d1d08e Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 15:29:51 +1000 Subject: [PATCH 1/2] fix(web): let operators close expired approvals and retry stuck agent runs Two dead ends where the only escape was the database. Approvals: decide() refused BOTH verdicts once expiresAt passed, and nothing ever moved a row out of pending, so an expired request sat in the inbox forever offering an Approve button that could never succeed. - Approving past the deadline stays blocked. That is what expiry is for. - Rejecting is now allowed: it is strictly de-escalating and it is the only way to close the row with a recorded reason. - expireOverdue() transitions overdue rows to the 'expired' status the enum already had, with an audit event, lazily on inbox read so a workspace self-heals without a scheduler. - Command stops counting overdue rows as pending approvals. - The inbox drops Approve once overdue and offers 'Reject and close', instead of rendering live buttons above 'Approval has expired.' Agent runs: re-dispatching a failed task already worked, but a run wedged at queued/running/awaiting_approval blocked the button permanently and POST /tasks/[id]/cancel was wired nowhere in the portal. - Cancel run appears whenever a run is in flight. - A settled failure now reads 'Retry dispatch' and says what happened. - The blocked message names the way out rather than just refusing. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/app/api/v1/approvals/route.ts | 5 +- .../features/approvals/governance-inbox.tsx | 38 +++++---- .../operations/operations-view.test.ts | 28 ++++++- .../features/operations/operations-view.tsx | 82 +++++++++++++++---- apps/web/lib/approval-expiry.test.ts | 55 +++++++++++++ apps/web/lib/command-summary-domain.ts | 5 +- apps/web/lib/integration-action-domain.ts | 58 ++++++++++++- apps/web/lib/queries/hooks.ts | 25 ++++++ 8 files changed, 261 insertions(+), 35 deletions(-) create mode 100644 apps/web/lib/approval-expiry.test.ts 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..efaf6de 100644 --- a/apps/web/features/approvals/governance-inbox.tsx +++ b/apps/web/features/approvals/governance-inbox.tsx @@ -209,7 +209,11 @@ 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; + const closable = approval.status === "pending" && overdue; return (
@@ -277,9 +281,15 @@ function ApprovalDetail({
- {pending ? ( + {pending || closable ? (
-