fix(web): close expired approvals and retry stuck agent runs - #100
Conversation
… 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) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughApproval expiry is now enforced during listing and actionability checks, with updated inbox controls for overdue approvals. Operations adds active-run detection, cancellation, retry labeling, and related query invalidation. ChangesApproval expiry handling
Task-run cancellation and retry handling
Estimated code review effort: 3 (Moderate) | ~30 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant DetailDrawer
participant useCancelTaskRun
participant CancelEndpoint
Operator->>DetailDrawer: Select Cancel run
DetailDrawer->>useCancelTaskRun: mutateAsync(taskId)
useCancelTaskRun->>CancelEndpoint: POST /api/v1/tasks/:taskId/cancel
CancelEndpoint-->>useCancelTaskRun: cancellation status
useCancelTaskRun-->>DetailDrawer: invalidate task, summary, and audit queries
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/web/features/operations/operations-view.test.ts (1)
32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftTest rendered run-state behavior, not TSX text.
Reading the component source passes even if the status helpers are no longer used or the controls are never rendered.
apps/web/features/operations/operations-view.test.ts#L32-L32: render a synthetic active-run item and assert the dispatch control is disabled with the blocking guidance.apps/web/features/operations/operations-view.test.ts#L106-L131: render synthetic items for every active status plusfailedandcancelled; assert cancellation visibility and retry labeling, and mock the cancellation mutation.As per coding guidelines, “Use synthetic data in tests, demos, screenshots, and documentation.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/features/operations/operations-view.test.ts` at line 32, Replace source-text assertions in apps/web/features/operations/operations-view.test.ts lines 32-32 with rendered synthetic active-run data, asserting the dispatch control is disabled and shows the blocking guidance. Update lines 106-131 to render synthetic items for every active status plus failed and cancelled, mock the cancellation mutation, and assert cancellation visibility and retry labeling.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/lib/integration-action-domain.ts`:
- Around line 430-441: Allow expired approvals to be rejected end-to-end: in
apps/web/lib/integration-action-domain.ts lines 430-441, bypass the duplicate
return for expired approvals when decision.status is "rejected"; in
apps/web/features/approvals/governance-inbox.tsx lines 212-216, include
"expired" approvals in closable; in lines 284-342, render the reason field and
“Reject and close” action for stored expired rows; in
apps/web/lib/approval-expiry.test.ts lines 13-54, add a test that lists
synthetic data to expire an approval, rejects it, and asserts the final status
is "rejected".
In `@apps/web/lib/queries/hooks.ts`:
- Around line 347-351: Update useCancelTaskRun and the corresponding task-cancel
API endpoint to send and process a stable idempotency key for each cancellation
intent. Enforce the key server-side before invoking the gateway or emitting
settleAgentRun, returning the previously recorded result for duplicate requests
while preserving the existing behavior for new cancellations.
---
Nitpick comments:
In `@apps/web/features/operations/operations-view.test.ts`:
- Line 32: Replace source-text assertions in
apps/web/features/operations/operations-view.test.ts lines 32-32 with rendered
synthetic active-run data, asserting the dispatch control is disabled and shows
the blocking guidance. Update lines 106-131 to render synthetic items for every
active status plus failed and cancelled, mock the cancellation mutation, and
assert cancellation visibility and retry labeling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 77171294-9ea7-4f72-be93-6940f5a3b0c7
📒 Files selected for processing (8)
apps/web/app/api/v1/approvals/route.tsapps/web/features/approvals/governance-inbox.tsxapps/web/features/operations/operations-view.test.tsapps/web/features/operations/operations-view.tsxapps/web/lib/approval-expiry.test.tsapps/web/lib/command-summary-domain.tsapps/web/lib/integration-action-domain.tsapps/web/lib/queries/hooks.ts
| mutationFn: async (taskId: string) => { | ||
| const res = await apiPost<{ status?: string }>( | ||
| `/api/v1/tasks/${taskId}/cancel`, | ||
| {}, | ||
| ); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the cancellation route and any existing idempotency handling.
fd -t f 'route.ts' apps/web/app -x rg -n -C3 'cancel|[Ii]dempotency' {} || true
# Inspect the API client contract for supported request headers/options.
rg -n -C3 'apiPost|RequestOptions|[Ii]dempotency' apps/web/lib/apiRepository: jusso-dev/Muster
Length of output: 10641
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '--- files ---\n'
git ls-files | rg '(^apps/web/app/api/v1/tasks/.*/route\.ts$|^apps/web/lib/api/client\.ts$|^apps/web/lib/queries/hooks\.ts$)' || true
printf '\n--- cancellation route ---\n'
fd -t f 'route.ts' apps/web/app/api/v1/tasks -x sh -c 'echo "### $1"; rg -n -C4 "cancel|idempotency" "$1" || true' sh {}
printf '\n--- api client ---\n'
cat -n apps/web/lib/api/client.ts | sed -n '1,150p'
printf '\n--- hooks slice ---\n'
cat -n apps/web/lib/queries/hooks.ts | sed -n '320,370p'
printf '\n--- deterministic apiRequest header extraction probe ---\n'
python3 - <<'PY'
from pathlib import Path
import re
text = Path("apps/web/lib/api/client.ts").read_text()
m = re.search(r"export async function apiRequest<T>[\s\S]*?\n\}", text)
print("apiRequest exists:", bool(m))
if m:
block = m.group(0)
for needle in ["idempotency-key", "fetch(", "headers:", "RequestInit", "body:"]:
print(f"{needle}: {needle in block}")
print("snippet:\n" + block)
PYRepository: jusso-dev/Muster
Length of output: 245
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- files ---'
git ls-files | rg '(^apps/web/app/api/v1/tasks/.*/route\.ts$|^apps/web/lib/api/client\.ts$|^apps/web/lib/queries/hooks\.ts$)' || true
printf '%s\n' ''
printf '%s\n' '--- cancellation route ---'
fd -t f 'route.ts' apps/web/app/api/v1/tasks -x sh -c 'echo "### $1"; rg -n -C4 "cancel|idempotency" "$1" || true' sh {}
printf '%s\n' ''
printf '%s\n' '--- api client ---'
cat -n apps/web/lib/api/client.ts | sed -n '1,150p'
printf '%s\n' ''
printf '%s\n' '--- hooks slice ---'
cat -n apps/web/lib/queries/hooks.ts | sed -n '320,370p'
printf '%s\n' ''
printf '%s\n' '--- deterministic apiRequest header extraction probe ---'
python3 - <<'PY'
from pathlib import Path
import re
text = Path("apps/web/lib/api/client.ts").read_text()
m = re.search(r"export async function apiRequest<T>[\s\S]*?\n\}", text)
print("apiRequest exists:", bool(m))
if m:
block = m.group(0)
for needle in ["idempotency-key", "fetch(", "headers:", "RequestInit", "body:"]:
print(f"{needle}: {needle in block}")
print("snippet:\n" + block)
PYRepository: jusso-dev/Muster
Length of output: 13468
Make task cancellation idempotent server-side.
useCancelTaskRun() posts {} and apiPost only sends Accept and Content-Type, so duplicate cancellation requests have no stable intent key. Add idempotency support to this endpoint and enforce it before calling the gateway and emitting settleAgentRun.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/lib/queries/hooks.ts` around lines 347 - 351, Update
useCancelTaskRun and the corresponding task-cancel API endpoint to send and
process a stable idempotency key for each cancellation intent. Enforce the key
server-side before invoking the gateway or emitting settleAgentRun, returning
the previously recorded result for duplicate requests while preserving the
existing behavior for new cancellations.
Source: Coding guidelines
Self-inflicted by the previous commit and caught in review. expireOverdue() runs on every inbox list, so a row becomes 'expired' the moment anyone opens Approvals. decide() then hit its 'status !== pending' early return and silently answered duplicate:true, and the inbox only treated overdue *pending* rows as closable — so the act of opening the page removed the only way to close the row. Rejection now survives the expired state on both sides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 11 and 12. Two dead ends where the only escape was the database.
Expired approvals
ApprovalDomainService.decide()threw 409 onceexpiresAtpassed — on both verdicts. Nothing ever transitioned a row out ofpending, even though the status enum has hadexpiredall along. Result: an expired request sits in the inbox forever, rendering a live Approve button directly above the text "Approval has expired."expireOverdue()moves overdue rows toexpiredwith aworkflow.approval.expiredaudit event. It runs lazily on inbox read, so a workspace self-heals without adding a scheduler.Stuck and failed agent runs
Re-dispatching a
failedtask already worked (verified against the live homelab). The real trap was a run wedged atqueued,running, orawaiting_approval— gateway crash, expired lease, an approval nobody actioned. That blocked the Dispatch button permanently, andPOST /api/v1/tasks/[id]/cancelexisted but was wired nowhere in the portal.queued/running, sinceawaiting_approvalwedges a task just as hard.Tests
lib/approval-expiry.test.tspins both halves of the expiry rule, the lazy transition, the audit event, and the Command filter. The board tests cover all four active statuses so a future status added to the server contract doesn't silently become another wedge.One existing assertion was updated: it pinned the old "already has an active agent run" copy.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes