Skip to content

fix(web): close expired approvals and retry stuck agent runs - #100

Merged
jusso-dev merged 2 commits into
mainfrom
fix/approval-expiry-and-retry
Jul 30, 2026
Merged

fix(web): close expired approvals and retry stuck agent runs#100
jusso-dev merged 2 commits into
mainfrom
fix/approval-expiry-and-retry

Conversation

@jusso-dev

@jusso-dev jusso-dev commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Tasks 11 and 12. Two dead ends where the only escape was the database.

Expired approvals

ApprovalDomainService.decide() threw 409 once expiresAt passed — on both verdicts. Nothing ever transitioned a row out of pending, even though the status enum has had expired all along. Result: an expired request sits in the inbox forever, rendering a live Approve button directly above the text "Approval has expired."

  • Approve past the deadline stays blocked. That is precisely what expiry protects against.
  • Reject is now allowed. It is strictly de-escalating, and it is the only way to close the row with a reason on the audit trail.
  • expireOverdue() moves overdue rows to expired with a workflow.approval.expired audit event. It runs lazily on inbox read, so a workspace self-heals without adding a scheduler.
  • Command no longer counts overdue rows as pending approvals — an unactionable row should never inflate the attention queue.
  • The inbox drops Approve once overdue and offers Reject and close, with an explanation instead of a contradiction.

Stuck and failed agent runs

Re-dispatching a failed task already worked (verified against the live homelab). The real trap was a run wedged at queued, running, or awaiting_approval — gateway crash, expired lease, an approval nobody actioned. That blocked the Dispatch button permanently, and POST /api/v1/tasks/[id]/cancel existed but was wired nowhere in the portal.

  • Cancel run appears whenever a run is in flight, covering all four statuses the server treats as active — not just queued/running, since awaiting_approval wedges a task just as hard.
  • A settled failure now reads Retry dispatch and states what happened to the previous run, rather than presenting an identical button.
  • The blocked message names the escape ("Cancel it before dispatching again") instead of just refusing.

Tests

lib/approval-expiry.test.ts pins 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

    • Added the ability to cancel in-flight agent runs.
    • Added clearer retry actions and messaging for failed or cancelled runs.
    • Added improved handling for approvals that pass their deadlines.
  • Bug Fixes

    • Prevented overdue approvals from being treated as actionable.
    • Approving expired requests is now blocked, while eligible requests can still be rejected and closed.
    • Improved messaging when dispatching is blocked by an active run.

… 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>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jusso-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 762ec1ae-4bd5-4ed4-a84a-c4703c930a0b

📥 Commits

Reviewing files that changed from the base of the PR and between 8846f69 and b54e640.

📒 Files selected for processing (3)
  • apps/web/features/approvals/governance-inbox.tsx
  • apps/web/lib/approval-expiry.test.ts
  • apps/web/lib/integration-action-domain.ts
📝 Walkthrough

Walkthrough

Approval 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.

Changes

Approval expiry handling

Layer / File(s) Summary
Approval expiry lifecycle
apps/web/lib/integration-action-domain.ts
Overdue pending approvals transition to expired, audit events are emitted, and expired approvals can be rejected but not approved.
Actionable approval filtering
apps/web/lib/command-summary-domain.ts, apps/web/app/api/v1/approvals/route.ts
Overdue approvals are excluded from actionable summaries, and the approvals route passes its trace identifier to the domain service.
Approval inbox controls and validation
apps/web/features/approvals/governance-inbox.tsx, apps/web/lib/approval-expiry.test.ts
Overdue approvals show adjusted decision controls and expired outcomes, with tests covering expiry and inbox behavior.

Task-run cancellation and retry handling

Layer / File(s) Summary
Run-state controls and retry messaging
apps/web/features/operations/operations-view.tsx, apps/web/features/operations/operations-view.test.ts
Active runs can be canceled, duplicate dispatch is blocked, and failed or canceled runs are labeled as retries.
Cancellation mutation and cache updates
apps/web/lib/queries/hooks.ts
useCancelTaskRun posts cancellation requests and invalidates task, command-summary, and audit caches.

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
Loading

Possibly related PRs

  • jusso-dev/Muster#41: Adds backend and gateway support for the cancellation path used by the new UI hook.
  • jusso-dev/Muster#42: Adds organization-scoped gateway headers to the task cancellation endpoint.
  • jusso-dev/Muster#93: Extends the approvals and query-hook foundation touched by this change.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main changes: expired approvals can be closed and stuck agent runs can be retried.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/approval-expiry-and-retry

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/web/features/operations/operations-view.test.ts (1)

32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Test 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 plus failed and cancelled; 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

📥 Commits

Reviewing files that changed from the base of the PR and between 763e9d4 and 8846f69.

📒 Files selected for processing (8)
  • apps/web/app/api/v1/approvals/route.ts
  • apps/web/features/approvals/governance-inbox.tsx
  • apps/web/features/operations/operations-view.test.ts
  • apps/web/features/operations/operations-view.tsx
  • apps/web/lib/approval-expiry.test.ts
  • apps/web/lib/command-summary-domain.ts
  • apps/web/lib/integration-action-domain.ts
  • apps/web/lib/queries/hooks.ts

Comment thread apps/web/lib/integration-action-domain.ts Outdated
Comment on lines +347 to +351
mutationFn: async (taskId: string) => {
const res = await apiPost<{ status?: string }>(
`/api/v1/tasks/${taskId}/cancel`,
{},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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/api

Repository: 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)
PY

Repository: 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)
PY

Repository: 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>
@jusso-dev
jusso-dev merged commit addb93d into main Jul 30, 2026
8 checks passed
@jusso-dev
jusso-dev deleted the fix/approval-expiry-and-retry branch July 30, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant