Permit to re-run the failed run's #8

Closed
opened 2026-10-27 23:50:46 +00:00 by SebastienDenooz · 16 comments

Add a button on the top right of the page to re-run the "run" on failed run.

Functional analysis

Expected behaviour

When an operator opens a run that ended in failed, they can restart it from the admin UI without going to the forge to add the retry command label. A Re-run button sits in the top-right corner of the run detail page (/runs/{id}), next to the "Run #id · phase" title. Clicking it, and then confirming, starts the same flow as the retry command label.

All five defaults were accepted by @SebastienDenooz on 2026-10-28:

  • q1 run_detail: the button is only on the run detail page.
  • q2 same_as_retry_command: a re-run works exactly like the retry command label.
  • q3 latest_failed_only: only the latest run of a mailbox can be re-run, and only when it failed.
  • q4 failed_only: cancelled runs are excluded.
  • q5 authorized_with_confirm: only users who can manage the project may re-run, and the click must be confirmed.

Acceptance criteria

  1. Placement: the Re-run button appears in the top-right corner of the run detail page header, next to the "Run #id · phase" title. It is not added to the runs list or to the mailbox page.
  2. Eligibility: the button appears only when all of these hold:
    • the run is in state failed;
    • it is the latest run of its mailbox;
    • no other run of that mailbox is pending or running.
  3. Hidden otherwise: the button does not appear for runs that are pending, running, succeeded or cancelled. It also does not appear for a failed run that a newer run of the same mailbox has superseded.
  4. Authorization: only users who can manage the run's project see the button. If any other user calls the re-run action directly, they get a 403. Users who can only view the run still see the page, but without the button.
  5. Confirmation: clicking the button opens a confirmation dialog. Cancelling it does nothing. Confirming it starts the re-run.
  6. Re-run behaviour: the re-run does exactly what the existing retry command label does for that mailbox:
    • reset the counters;
    • plan the failed phase again;
    • post the "resumed" comment on the forge issue;
    • restore the status label.
  7. Feedback: after confirming, the user is taken to the newly planned run. If no run can be shown yet, they see a success message instead.
  8. Stale requests: if the run is no longer eligible when the action reaches the server (another run was started in the meantime, or the state changed), no re-run happens. The user sees an explanatory error.
  9. Tests:
    • Feature tests cover button visibility for each state and eligibility case, the authorization (403), the re-run being equivalent to the retry command, and the rejection of stale requests.
    • A Playwright e2e test covers the confirm-and-re-run path from the run detail page.
    • Both test suites run in GitLab CI and in Forgejo/GitHub Actions.
Add a button on the top right of the page to re-run the "run" on failed run. <!-- agent:section:start --> ## Functional analysis ### Expected behaviour When an operator opens a run that ended in **failed**, they can restart it from the admin UI without going to the forge to add the `retry` command label. A **Re-run** button sits in the top-right corner of the run detail page (`/runs/{id}`), next to the "Run #id · phase" title. Clicking it, and then confirming, starts the same flow as the `retry` command label. All five defaults were accepted by @SebastienDenooz on 2026-10-28: - q1 `run_detail`: the button is only on the run detail page. - q2 `same_as_retry_command`: a re-run works exactly like the `retry` command label. - q3 `latest_failed_only`: only the latest run of a mailbox can be re-run, and only when it failed. - q4 `failed_only`: cancelled runs are excluded. - q5 `authorized_with_confirm`: only users who can manage the project may re-run, and the click must be confirmed. ### Acceptance criteria 1. **Placement:** the **Re-run** button appears in the top-right corner of the run detail page header, next to the "Run #id · phase" title. It is not added to the runs list or to the mailbox page. 2. **Eligibility:** the button appears only when all of these hold: - the run is in state `failed`; - it is the latest run of its mailbox; - no other run of that mailbox is `pending` or `running`. 3. **Hidden otherwise:** the button does not appear for runs that are `pending`, `running`, `succeeded` or `cancelled`. It also does not appear for a failed run that a newer run of the same mailbox has superseded. 4. **Authorization:** only users who can manage the run's project see the button. If any other user calls the re-run action directly, they get a 403. Users who can only view the run still see the page, but without the button. 5. **Confirmation:** clicking the button opens a confirmation dialog. Cancelling it does nothing. Confirming it starts the re-run. 6. **Re-run behaviour:** the re-run does exactly what the existing `retry` command label does for that mailbox: - reset the counters; - plan the failed phase again; - post the "resumed" comment on the forge issue; - restore the status label. 7. **Feedback:** after confirming, the user is taken to the newly planned run. If no run can be shown yet, they see a success message instead. 8. **Stale requests:** if the run is no longer eligible when the action reaches the server (another run was started in the meantime, or the state changed), no re-run happens. The user sees an explanatory error. 9. **Tests:** - Feature tests cover button visibility for each state and eligibility case, the authorization (403), the re-run being equivalent to the `retry` command, and the rejection of stale requests. - A Playwright e2e test covers the confirm-and-re-run path from the run detail page. - Both test suites run in GitLab CI and in Forgejo/GitHub Actions. <!-- agent:key=018f7a1d15b3:kind=description --> <!-- agent:section:end -->
Owner

Thanks @SebastienDenooz, I am on it. I will post my analysis here.

Thanks @SebastienDenooz, I am on it. I will post my analysis here. <!-- agent:key=97804fcaa721:kind=acknowledgement -->
Owner

The status label status::functional_analysis is managed by me: I restored it. Use the command labels to steer my work.

The status label status::functional_analysis is managed by me: I restored it. Use the command labels to steer my work. <!-- agent:key=59aeca08e35a:kind=status_label_restored -->
Owner

I need a few answers before going further. Reply in a comment; without an answer by 2026-10-28 23:51 UTC, I will apply the defaults.

q1 — Which page should show the "Re-run" button in its top-right corner? The run detail page (/runs/{id}, title "Run #id · phase") is the only page about a single run. The runs list and the mailbox page show many runs.

  • run_detail — Only on the detail page of a failed run, next to the "Run #id" title (default)
  • run_detail_and_list — On the detail page, plus a re-run action on each failed row of the runs list

q2 — What should a re-run do? The engine already has a retry flow: the retry command label on a blocked issue resets the counters, plans the failed phase again, posts a "resumed" comment on the forge and restores the status label. Should the button use this same flow, or should it only start a new run of the same phase with the same trigger events, without touching the forge?

  • same_as_retry_command — Behave exactly like the retry command label: reset counters, plan the failed phase again, post the resumption comment and restore the status label (default)
  • new_run_only — Create a new pending run (same mailbox, phase and trigger events, attempt + 1) and let the dispatcher pick it up, with no forge comment or label change

q3 — Which failed runs can be re-run? A mailbox can have several failed runs over time, and newer runs may already have moved the issue on.

  • latest_failed_only — Show the button only when the run failed, is the latest run of its mailbox, and no other run of that mailbox is pending or running (default)
  • any_failed — Show the button on every failed run, whatever happened after it

q4 — Should the button also appear on cancelled runs, or only on failed ones?

  • failed_only — Only runs in state failed, as the issue says (default)
  • failed_and_cancelled — Runs in state failed or cancelled

q5 — Who may re-run, and should the click be confirmed first?

  • authorized_with_confirm — Any user who can manage the run's project; a confirmation dialog appears before the re-run starts, and afterwards the user is taken to the new run (or shown a success message) (default)
  • viewer_no_confirm — Any user who can view the run; the re-run starts at once with no confirmation

You can also answer every question at once in a comment of your own.

I need a few answers before going further. Reply in a comment; without an answer by 2026-10-28 23:51 UTC, I will apply the defaults. <!-- agent:questions:block=7 --> **q1 — Which page should show the "Re-run" button in its top-right corner? The run detail page (`/runs/{id}`, title "Run #id · phase") is the only page about a single run. The runs list and the mailbox page show many runs.** <!-- agent:q=q1 --> - [x] run_detail — Only on the detail page of a failed run, next to the "Run #id" title *(default)* - [ ] run_detail_and_list — On the detail page, plus a re-run action on each failed row of the runs list **q2 — What should a re-run do? The engine already has a retry flow: the `retry` command label on a blocked issue resets the counters, plans the failed phase again, posts a "resumed" comment on the forge and restores the status label. Should the button use this same flow, or should it only start a new run of the same phase with the same trigger events, without touching the forge?** <!-- agent:q=q2 --> - [x] same_as_retry_command — Behave exactly like the `retry` command label: reset counters, plan the failed phase again, post the resumption comment and restore the status label *(default)* - [ ] new_run_only — Create a new pending run (same mailbox, phase and trigger events, attempt + 1) and let the dispatcher pick it up, with no forge comment or label change **q3 — Which failed runs can be re-run? A mailbox can have several failed runs over time, and newer runs may already have moved the issue on.** <!-- agent:q=q3 --> - [x] latest_failed_only — Show the button only when the run failed, is the latest run of its mailbox, and no other run of that mailbox is pending or running *(default)* - [ ] any_failed — Show the button on every failed run, whatever happened after it **q4 — Should the button also appear on cancelled runs, or only on failed ones?** <!-- agent:q=q4 --> - [x] failed_only — Only runs in state `failed`, as the issue says *(default)* - [ ] failed_and_cancelled — Runs in state `failed` or `cancelled` **q5 — Who may re-run, and should the click be confirmed first?** <!-- agent:q=q5 --> - [x] authorized_with_confirm — Any user who can manage the run's project; a confirmation dialog appears before the re-run starts, and afterwards the user is taken to the new run (or shown a success message) *(default)* - [ ] viewer_no_confirm — Any user who can view the run; the re-run starts at once with no confirmation You can also answer every question at once in a comment of your own. <!-- agent:key=64e60d6a98a0:kind=questions -->
Author
Owner

Ok for me, you can go forward

Ok for me, you can go forward
Owner

I need a few answers before going further. Reply in a comment; without an answer by 2026-10-29 08:55 UTC, I will apply the defaults.

q1 — A failed run does not always leave its issue blocked, and the retry command label only acts on a blocked issue (TriggerResolver line 49 and IssueLifecycle line 106 both require status blocked with a failed phase). An issue becomes blocked only when the LLM retries run out (or because of a type conflict or a closed MR). For the other failures the issue stays where it was. Non-retryable failures (auth_error, setup_failed, provider_not_allowed, unhandled_phase, host or read-only violations) keep the issue in its status (for example in_development) with failed_phase set, and nothing restarts it; adding the retry label does nothing today either. Transient failures already have an automatic internal.retry_due queued. Stale or lease-expired runs are sent back to the dispatcher automatically. What should the Re-run button do for a failed run whose issue is not blocked?

  • blocked_only — Show the button only when the mailbox is blocked with a failed phase, which is exactly where the retry label works. Hide it on every other failed run, so the behaviour stays exactly that of the label. (default)
  • also_terminal_failures — Also show it when the issue is not blocked and no automatic retry or other event is waiting. In that case the re-run plans the failed phase again with the failed run's trigger events, resets llm_retries, posts the "resumed" comment and restores the status label, but changes no status (this goes beyond what the label does).

You can also answer every question at once in a comment of your own.

I need a few answers before going further. Reply in a comment; without an answer by 2026-10-29 08:55 UTC, I will apply the defaults. <!-- agent:questions:block=8 --> **q1 — A failed run does not always leave its issue blocked, and the `retry` command label only acts on a blocked issue (`TriggerResolver` line 49 and `IssueLifecycle` line 106 both require status `blocked` with a failed phase). An issue becomes blocked only when the LLM retries run out (or because of a type conflict or a closed MR). For the other failures the issue stays where it was. Non-retryable failures (`auth_error`, `setup_failed`, `provider_not_allowed`, `unhandled_phase`, host or read-only violations) keep the issue in its status (for example `in_development`) with `failed_phase` set, and nothing restarts it; adding the `retry` label does nothing today either. Transient failures already have an automatic `internal.retry_due` queued. Stale or lease-expired runs are sent back to the dispatcher automatically. What should the Re-run button do for a failed run whose issue is not blocked?** <!-- agent:q=q1 --> - [x] blocked_only — Show the button only when the mailbox is `blocked` with a failed phase, which is exactly where the `retry` label works. Hide it on every other failed run, so the behaviour stays exactly that of the label. *(default)* - [ ] also_terminal_failures — Also show it when the issue is not blocked and no automatic retry or other event is waiting. In that case the re-run plans the failed phase again with the failed run's trigger events, resets `llm_retries`, posts the "resumed" comment and restores the status label, but changes no status (this goes beyond what the label does). You can also answer every question at once in a comment of your own. <!-- agent:key=6adcd35c4aef:kind=questions -->
Author
Owner

I just want a retry button on run's that is in state failed. Because i have a run that it is in a state failed" where the error of the commit is lease_expired. And i just want this run to run again. Is it that you understand and talking about or not?

I just want a retry button on `run's` that is in state `failed`. Because i have a `run` that it is in a state `failed`" where the error of the commit is `lease_expired`. And i just want this `run` to run again. Is it that you understand and talking about or not?
Owner

Plan

Summary

This plan adds a Re-run button to the run detail page (/runs/{id}). It goes through the same machinery as the retry command label, and it also covers the case @SebastienDenooz asked about: a run that failed with lease_expired and that nothing restarts today.

Why the button cannot just add the retry label

The exploration confirmed the gap behind the last question. When LeaseReclaimer or AbortStaleRuns interrupts a run:

  1. The run is marked failed with lease_expired or stale_run.
  2. RunExecutor::fail() sees wasAborted() and returns early. No retry_due event is created and failed_phase is not set.
  3. The run's trigger events were already consumed, so RunDispatcher::plan() finds an empty buffer and sets the mailbox back to idle.

The issue keeps its working status (for example in_development) with no pending run. The retry label only acts on blocked issues, so it cannot help here.

What the button does

The button follows the same mechanism as the label and adds one case:

  • Blocked issue: the re-run uses the RetryCommand transition and is exactly what the retry label does (q2 and the first q1 default).
  • Issue not blocked (lease_expired, stale_run, auth_error, setup_failed, …): a new RerunRequested trigger does the same work but keeps the status. It resets the counters of the failed phase, plans the failed run's phase again with its trigger events, posts the "Retrying phase." comment and restores the status label. This matches the user's request: "I just want this run to run again."

The eligibility rules stay as accepted (latest run of the mailbox, state failed, nothing pending or running), and so do the authorization and confirmation rules.

Approach

1. A new internal event: internal.rerun_requested

  • app/Enums/EventKind.php: add case InternalRerunRequested = 'internal.rerun_requested';.
    • inbox_events.kind is a free string, so no migration is needed.
    • The Coalescer default branch already puts unknown kinds in otherEvents.
    • RunIntent::hasTrigger() already counts otherEvents, so the event is always consumed.
    • Add the kind to any label or translation map of event kinds used by the admin screens (grep EventKind:: in resources/views and lang/).
  • Payload: {failed_run_id, phase, trigger_event_ids, requested_by}, where requested_by is the user id.
  • Delivery: the event is written through the existing MailboxWaker::wake($mailbox, EventKind::InternalRerunRequested, "rerun:run-{id}", $payload). It moves an idle mailbox to buffering, due now, and dispatches ProcessMailbox. The key makes a double click idempotent.

2. Lifecycle

app/Lifecycle/LifecycleTrigger.php: add RerunRequested.

app/Lifecycle/TriggerResolver.php: add two arms right after the retry command arms (line 49). They come after MrMerged, MrClosed, MrReopened and type conflicts, so those keep priority.

$other(EventKind::InternalRerunRequested) && $status === IssueStatus::Blocked => LifecycleTrigger::RetryCommand,
$other(EventKind::InternalRerunRequested) && in_array($status, self::RERUNNABLE, true) => LifecycleTrigger::RerunRequested,
  • RERUNNABLE is FunctionalAnalysis, TechnicalAnalysis, InDevelopment, InCi and InReview.
  • Waiting statuses are excluded on purpose. Re-applying them would clear waiting_reason and resume_phase in statusAttributes().

app/Lifecycle/IssueLifecycle.php:

T::RerunRequested => $is(...RERUNNABLE) && $state->failedPhase !== null
    ? new Transition($status, $state->failedPhase, [E::ResetCounter, $label]) : throw $invalid(),

app/Providers/LifecycleServiceProvider.php (lines 80-82): register RetryCommandHandler for RerunRequested as well. It already does everything needed:

  • removes any retry command labels (a no-op when none are present);
  • posts retry_resumed, and its idempotency key is derived from the new event id, so the comment is posted again for each re-run;
  • applies nextStatus as the status label.

Update the handler docblock.

3. Dispatcher: app/Scheduling/RunDispatcher.php

  • Read the event: next to $retry (line 100), read $rerun = $intent->otherEvents[EventKind::InternalRerunRequested->value] ?? null;.
  • transition(): for RerunRequested, pass failedPhase: Phase::tryFrom(payload.phase) through the existing $failedPhase override (line 127). For RetryCommand coming from a re-run, keep $mailbox->failed_phase; it is set on a blocked issue.
  • triggerEventIds($intent, $retry ?? $rerun): the new run gets the failed run's trigger_event_ids, so the agent sees the same context. This mirrors the retry_due replay.
  • attempt: when $rerun is present, use failed_run.attempt + 1. Read it from failed_run_id, with a fallback to the current rule.
  • statusAttributes(): no change. ResetCounter resets the counter of $mailbox->failed_phase when it is set (non-retryable failures), then clears it. For lease_expired, failed_phase is already null and nothing is reset, which is correct because no counter was incremented.
  • directPhase(), MR mailboxes: MR mailboxes have no issue status and only run Review. Accept $rerun !== null as a review request (closed_at === null && ($requested || $rerun !== null)). That case posts no comment and applies no label, because the MR has no status label.

4. Domain service: app/Runs/RerunFailedRun.php

The view and the controller both use this service, so there is a single source of truth.

refusal(Run $run): ?string returns null when the run is eligible, otherwise a translated reason. The run is eligible when all of these hold:

  • state === Failed;
  • the run is the latest run of its mailbox (max(id));
  • no run of the mailbox is pending or running;
  • the mailbox state is neither Running nor Cancelling;
  • for an issue mailbox: closed_at === null and the status is Blocked with failed_phase, or the status is in RERUNNABLE;
  • for an MR mailbox: closed_at === null.

rerun(Run $run, User $user): void:

  1. Inside DB::transaction, lock the mailbox (lockForUpdate) and check refusal() again. If the run is no longer eligible, throw RunNotRerunnable (new, in app/Runs/, carrying the reason).
  2. Otherwise call MailboxWaker::wake(...).

Concurrency is handled by the lock plus MailboxVersion (the waker already retries on StaleMailboxVersion).

5. Authorization: app/Policies/RunPolicy.php

Add rerun(User $user, Run $run): bool, which returns $user->can('update', $run->mailbox->project). That is the existing "manage project" ability, owners only. Viewers (members) still pass view and see the page without the button. Direct POST calls by other users get 403 from Gate::authorize.

6. HTTP

  • routes/web.php: next to runs.show (around line 86), add Route::post('/runs/{run}/rerun', [RunController::class, 'rerun'])->whereNumber('run')->name('runs.rerun');.
  • RunController::rerun(Request, Run, RerunFailedRun), following the MailboxController::replay pattern:
    1. CurrentOrganization::abortUnlessOwns, then Gate::authorize('rerun', $run).
    2. Call rerun(). On RunNotRerunnable, redirect back to runs.show($run) with error set to the reason (acceptance criterion 8).
    3. On success, look up the newest run of the mailbox. If its id is greater than $run->id (the sync queue in tests, or a fast worker), redirect to it with status "Re-run started.". Otherwise redirect back to the run with status "Re-run requested: the new run will appear on the mailbox shortly." (criterion 7).
  • RunController::data(): add status, state, failed_phase and closed_at to the eager-loaded mailbox columns. Pass canRerun = $request->user()->can('rerun', $run) && $rerun->refusal($run) === null to the view.

7. View

  • resources/views/runs/show.blade.php:12: wrap the header in flex items-start justify-between gap-4, as in mailboxes/show.blade.php:10. When $canRerun is true, put on the right:
    <x-confirm id="rerun-run" :action="route('runs.rerun', $run)" method="POST" variant="primary"
               :trigger="__('Re-run')" :title="__('Re-run run #:id?', ['id' => $run->id])" :confirm="__('Re-run')">
        {{ __('The :phase phase is planned again with the same trigger events, its counters reset, and a comment is posted on the issue.', [...]) }}
    </x-confirm>
    
  • resources/views/components/confirm.blade.php: add an optional variant prop. It defaults to danger, so existing uses are unchanged, and lets the re-run use a non-destructive style through x-form.button.
  • No JS change: resources/js/app.js already drives the <dialog>. The live polling only refreshes runs/partials/live.blade.php, so the button stays until a reload. A click after the run changed is caught by the server check of criterion 8.
  • Add the new strings to the lang/ JSON files, if any exist beyond en.

8. Docs

  • docs/specification.md:
    • §27 (admin actions): describe Re-run.
    • §11.5 and §11.7: aborted runs are not retried automatically, but can be re-run from the admin.
    • §13.5: the RerunRequested trigger.
    • Add the new event kind to the events list.

Tests

Unit

  • tests/Unit/Lifecycle/TriggerResolverTest.php:
    • a re-run event on a blocked issue gives RetryCommand;
    • on each RERUNNABLE status it gives RerunRequested;
    • on waiting, done or null statuses it gives null;
    • MrMerged and MrClosed take priority.
  • tests/Unit/Lifecycle/IssueLifecycleTest.php: RerunRequested keeps the status, runs failedPhase, and carries [ResetCounter, ApplyStatusLabel]. It is invalid without a failed phase or on waiting or blocked statuses.

Feature

tests/Feature/Scheduling/RunDispatcherTest.php:

  • A lease_expired run on an in_development mailbox, with a re-run event: one pending Development run is created with the failed run's trigger_event_ids and attempt + 1. The mailbox is running with its status unchanged. fakeForge records the "Retrying development." comment and the status label.
  • Non-retryable failure (auth_error, failed_phase set, llm_retries > 0): llm_retries is 0 and failed_phase is null.
  • Equivalence with the label: a blocked mailbox re-run through the event and the same mailbox retried through a writer retry label produce the same mailbox columns, run phase, comment and label calls. Reuse the blockedSetup() and blockedEvent() helpers from tests/Feature/Lifecycle/Blocked/BlockedTest.php.
  • An MR mailbox re-run plans Review.
  • A second re-run comment is posted again, which proves the idempotency key is per event.

tests/Feature/Admin/Runs/RunRerunTest.php (new; reuse runMember() and tracedRun() from RunScreensTest):

  • Visibility (dataset): the button shows for failed + latest + idle. It is hidden for pending, running, succeeded and cancelled, for a failed run superseded by a newer run, when another run is pending or running, when the mailbox is running or cancelling, and when the issue is closed or done.
  • Authorization:
    • an owner sees the button and a member does not;
    • a member's POST gets 403;
    • another organization gets 404;
    • a guest is redirected to login.
  • Success: the owner's POST (sync queue) redirects to the new run's page with a success flash, and the run, comment and label exist.
  • Stale requests: for each case (a newer run created meanwhile, the run is not failed, the mailbox is running), the POST creates no event and no run, and redirects back with session('error').
  • Idempotence: a double POST creates a single inbox event.

E2E: tests/Browser/Runs/RunRerunTest.php

Uses Pest browser, which runs Playwright. The path:

  1. Log in as the owner and open a failed lease_expired run.
  2. Click Re-run, then Cancel in the dialog: nothing changes (no new run).
  3. Click Re-run, then confirm: the page lands on the new run "Run #n · development", with the success message.

Use fakeForge, fakeContainerRuntime and fakeLlm, so the executed run does not touch the host.

CI

.gitlab-ci.yml, .github/workflows/ci.yml and .forgejo/workflows/ci.yml already run php artisan test (Unit, Feature and Browser with Playwright chromium), plus pint, phpstan and the build. The new tests run in all three pipelines without changes. Check that each pipeline picks up the new Browser test.

Risks and notes

  • Going beyond the retry label: for non-blocked issues the button does more than the label does today. This is deliberate, following @SebastienDenooz's comment. The retry label behaviour itself is unchanged.
  • Waiting statuses (waiting_info, waiting_validation) are not re-runnable. Re-applying them would clear waiting_reason and resume_phase. Failed runs there are rare, since the human answer triggers the next run anyway.
  • Transient failures with an automatic retry_due already queued: the mailbox is buffering, so the button shows. A re-run makes it due now, and both events are consumed by a single planned run. That is acceptable. Hiding the button in that case is an alternative if preferred.
  • Admission refusals (budget, quota, runtime): no run is created and the event stays buffered, as with any trigger. The user sees the "Re-run requested" message rather than a new run.
  • Authorization: the button is owner-only through ProjectPolicy::update ("manage project"), while mailbox cancel and replay are member-level. This follows criterion 4. If members should re-run too, it is a one-line change in RunPolicy::rerun.
  • Stale page: the button is not live-refreshed. The server re-checks eligibility under a mailbox lock, which covers criterion 8.

The implementation plan is ready for validation. Add the ai::validate_plan label to let me start.

## Plan ## Summary This plan adds a **Re-run** button to the run detail page (`/runs/{id}`). It goes through the same machinery as the `retry` command label, and it also covers the case @SebastienDenooz asked about: a run that failed with `lease_expired` and that nothing restarts today. ### Why the button cannot just add the `retry` label The exploration confirmed the gap behind the last question. When `LeaseReclaimer` or `AbortStaleRuns` interrupts a run: 1. The run is marked `failed` with `lease_expired` or `stale_run`. 2. `RunExecutor::fail()` sees `wasAborted()` and returns early. No `retry_due` event is created and `failed_phase` is not set. 3. The run's trigger events were already consumed, so `RunDispatcher::plan()` finds an empty buffer and sets the mailbox back to `idle`. The issue keeps its working status (for example `in_development`) with no pending run. The `retry` label only acts on `blocked` issues, so it cannot help here. ### What the button does The button follows the same mechanism as the label and adds one case: - **Blocked issue:** the re-run uses the `RetryCommand` transition and is exactly what the `retry` label does (q2 and the first q1 default). - **Issue not blocked** (`lease_expired`, `stale_run`, `auth_error`, `setup_failed`, …): a new `RerunRequested` trigger does the same work but keeps the status. It resets the counters of the failed phase, plans the failed run's phase again with its trigger events, posts the "Retrying `phase`." comment and restores the status label. This matches the user's request: "I just want this run to run again." The eligibility rules stay as accepted (latest run of the mailbox, state `failed`, nothing pending or running), and so do the authorization and confirmation rules. ## Approach ### 1. A new internal event: `internal.rerun_requested` - **`app/Enums/EventKind.php`:** add `case InternalRerunRequested = 'internal.rerun_requested';`. - `inbox_events.kind` is a free string, so no migration is needed. - The `Coalescer` default branch already puts unknown kinds in `otherEvents`. - `RunIntent::hasTrigger()` already counts `otherEvents`, so the event is always consumed. - Add the kind to any label or translation map of event kinds used by the admin screens (grep `EventKind::` in `resources/views` and `lang/`). - **Payload:** `{failed_run_id, phase, trigger_event_ids, requested_by}`, where `requested_by` is the user id. - **Delivery:** the event is written through the existing `MailboxWaker::wake($mailbox, EventKind::InternalRerunRequested, "rerun:run-{id}", $payload)`. It moves an idle mailbox to `buffering`, due now, and dispatches `ProcessMailbox`. The key makes a double click idempotent. ### 2. Lifecycle **`app/Lifecycle/LifecycleTrigger.php`:** add `RerunRequested`. **`app/Lifecycle/TriggerResolver.php`:** add two arms right after the retry command arms (line 49). They come after `MrMerged`, `MrClosed`, `MrReopened` and type conflicts, so those keep priority. ```php $other(EventKind::InternalRerunRequested) && $status === IssueStatus::Blocked => LifecycleTrigger::RetryCommand, $other(EventKind::InternalRerunRequested) && in_array($status, self::RERUNNABLE, true) => LifecycleTrigger::RerunRequested, ``` - `RERUNNABLE` is `FunctionalAnalysis`, `TechnicalAnalysis`, `InDevelopment`, `InCi` and `InReview`. - Waiting statuses are excluded on purpose. Re-applying them would clear `waiting_reason` and `resume_phase` in `statusAttributes()`. **`app/Lifecycle/IssueLifecycle.php`:** ```php T::RerunRequested => $is(...RERUNNABLE) && $state->failedPhase !== null ? new Transition($status, $state->failedPhase, [E::ResetCounter, $label]) : throw $invalid(), ``` **`app/Providers/LifecycleServiceProvider.php`** (lines 80-82): register `RetryCommandHandler` for `RerunRequested` as well. It already does everything needed: - removes any retry command labels (a no-op when none are present); - posts `retry_resumed`, and its idempotency key is derived from the new event id, so the comment is posted again for each re-run; - applies `nextStatus` as the status label. Update the handler docblock. ### 3. Dispatcher: `app/Scheduling/RunDispatcher.php` - **Read the event:** next to `$retry` (line 100), read `$rerun = $intent->otherEvents[EventKind::InternalRerunRequested->value] ?? null;`. - **`transition()`:** for `RerunRequested`, pass `failedPhase: Phase::tryFrom(payload.phase)` through the existing `$failedPhase` override (line 127). For `RetryCommand` coming from a re-run, keep `$mailbox->failed_phase`; it is set on a blocked issue. - **`triggerEventIds($intent, $retry ?? $rerun)`:** the new run gets the failed run's `trigger_event_ids`, so the agent sees the same context. This mirrors the `retry_due` replay. - **`attempt`:** when `$rerun` is present, use `failed_run.attempt + 1`. Read it from `failed_run_id`, with a fallback to the current rule. - **`statusAttributes()`:** no change. `ResetCounter` resets the counter of `$mailbox->failed_phase` when it is set (non-retryable failures), then clears it. For `lease_expired`, `failed_phase` is already null and nothing is reset, which is correct because no counter was incremented. - **`directPhase()`, MR mailboxes:** MR mailboxes have no issue status and only run `Review`. Accept `$rerun !== null` as a review request (`closed_at === null && ($requested || $rerun !== null)`). That case posts no comment and applies no label, because the MR has no status label. ### 4. Domain service: `app/Runs/RerunFailedRun.php` The view and the controller both use this service, so there is a single source of truth. **`refusal(Run $run): ?string`** returns null when the run is eligible, otherwise a translated reason. The run is eligible when all of these hold: - `state === Failed`; - the run is the latest run of its mailbox (`max(id)`); - no run of the mailbox is `pending` or `running`; - the mailbox `state` is neither `Running` nor `Cancelling`; - for an issue mailbox: `closed_at === null` and the status is `Blocked` with `failed_phase`, or the status is in `RERUNNABLE`; - for an MR mailbox: `closed_at === null`. **`rerun(Run $run, User $user): void`:** 1. Inside `DB::transaction`, lock the mailbox (`lockForUpdate`) and check `refusal()` again. If the run is no longer eligible, throw `RunNotRerunnable` (new, in `app/Runs/`, carrying the reason). 2. Otherwise call `MailboxWaker::wake(...)`. Concurrency is handled by the lock plus `MailboxVersion` (the waker already retries on `StaleMailboxVersion`). ### 5. Authorization: `app/Policies/RunPolicy.php` Add `rerun(User $user, Run $run): bool`, which returns `$user->can('update', $run->mailbox->project)`. That is the existing "manage project" ability, owners only. Viewers (members) still pass `view` and see the page without the button. Direct POST calls by other users get 403 from `Gate::authorize`. ### 6. HTTP - **`routes/web.php`:** next to `runs.show` (around line 86), add `Route::post('/runs/{run}/rerun', [RunController::class, 'rerun'])->whereNumber('run')->name('runs.rerun');`. - **`RunController::rerun(Request, Run, RerunFailedRun)`**, following the `MailboxController::replay` pattern: 1. `CurrentOrganization::abortUnlessOwns`, then `Gate::authorize('rerun', $run)`. 2. Call `rerun()`. On `RunNotRerunnable`, redirect back to `runs.show($run)` with `error` set to the reason (acceptance criterion 8). 3. On success, look up the newest run of the mailbox. If its id is greater than `$run->id` (the sync queue in tests, or a fast worker), redirect to it with `status` "Re-run started.". Otherwise redirect back to the run with `status` "Re-run requested: the new run will appear on the mailbox shortly." (criterion 7). - **`RunController::data()`:** add `status`, `state`, `failed_phase` and `closed_at` to the eager-loaded mailbox columns. Pass `canRerun = $request->user()->can('rerun', $run) && $rerun->refusal($run) === null` to the view. ### 7. View - **`resources/views/runs/show.blade.php:12`:** wrap the header in `flex items-start justify-between gap-4`, as in `mailboxes/show.blade.php:10`. When `$canRerun` is true, put on the right: ```blade <x-confirm id="rerun-run" :action="route('runs.rerun', $run)" method="POST" variant="primary" :trigger="__('Re-run')" :title="__('Re-run run #:id?', ['id' => $run->id])" :confirm="__('Re-run')"> {{ __('The :phase phase is planned again with the same trigger events, its counters reset, and a comment is posted on the issue.', [...]) }} </x-confirm> ``` - **`resources/views/components/confirm.blade.php`:** add an optional `variant` prop. It defaults to `danger`, so existing uses are unchanged, and lets the re-run use a non-destructive style through `x-form.button`. - No JS change: `resources/js/app.js` already drives the `<dialog>`. The live polling only refreshes `runs/partials/live.blade.php`, so the button stays until a reload. A click after the run changed is caught by the server check of criterion 8. - Add the new strings to the `lang/` JSON files, if any exist beyond `en`. ### 8. Docs - **`docs/specification.md`:** - §27 (admin actions): describe **Re-run**. - §11.5 and §11.7: aborted runs are not retried automatically, but can be re-run from the admin. - §13.5: the `RerunRequested` trigger. - Add the new event kind to the events list. ## Tests ### Unit - **`tests/Unit/Lifecycle/TriggerResolverTest.php`:** - a re-run event on a blocked issue gives `RetryCommand`; - on each `RERUNNABLE` status it gives `RerunRequested`; - on waiting, done or null statuses it gives null; - `MrMerged` and `MrClosed` take priority. - **`tests/Unit/Lifecycle/IssueLifecycleTest.php`:** `RerunRequested` keeps the status, runs `failedPhase`, and carries `[ResetCounter, ApplyStatusLabel]`. It is invalid without a failed phase or on waiting or blocked statuses. ### Feature **`tests/Feature/Scheduling/RunDispatcherTest.php`:** - A `lease_expired` run on an `in_development` mailbox, with a re-run event: one `pending` Development run is created with the failed run's `trigger_event_ids` and `attempt + 1`. The mailbox is `running` with its status unchanged. `fakeForge` records the "Retrying `development`." comment and the status label. - Non-retryable failure (`auth_error`, `failed_phase` set, `llm_retries` > 0): `llm_retries` is 0 and `failed_phase` is null. - **Equivalence with the label:** a blocked mailbox re-run through the event and the same mailbox retried through a writer `retry` label produce the same mailbox columns, run phase, comment and label calls. Reuse the `blockedSetup()` and `blockedEvent()` helpers from `tests/Feature/Lifecycle/Blocked/BlockedTest.php`. - An MR mailbox re-run plans `Review`. - A second re-run comment is posted again, which proves the idempotency key is per event. **`tests/Feature/Admin/Runs/RunRerunTest.php`** (new; reuse `runMember()` and `tracedRun()` from `RunScreensTest`): - **Visibility** (dataset): the button shows for failed + latest + idle. It is hidden for `pending`, `running`, `succeeded` and `cancelled`, for a failed run superseded by a newer run, when another run is pending or running, when the mailbox is running or cancelling, and when the issue is closed or done. - **Authorization:** - an owner sees the button and a member does not; - a member's POST gets 403; - another organization gets 404; - a guest is redirected to login. - **Success:** the owner's POST (sync queue) redirects to the new run's page with a success flash, and the run, comment and label exist. - **Stale requests:** for each case (a newer run created meanwhile, the run is not failed, the mailbox is running), the POST creates no event and no run, and redirects back with `session('error')`. - **Idempotence:** a double POST creates a single inbox event. ### E2E: `tests/Browser/Runs/RunRerunTest.php` Uses Pest browser, which runs Playwright. The path: 1. Log in as the owner and open a failed `lease_expired` run. 2. Click **Re-run**, then **Cancel** in the dialog: nothing changes (no new run). 3. Click **Re-run**, then confirm: the page lands on the new run "Run #n · development", with the success message. Use `fakeForge`, `fakeContainerRuntime` and `fakeLlm`, so the executed run does not touch the host. ### CI `.gitlab-ci.yml`, `.github/workflows/ci.yml` and `.forgejo/workflows/ci.yml` already run `php artisan test` (Unit, Feature and Browser with Playwright chromium), plus pint, phpstan and the build. The new tests run in all three pipelines without changes. Check that each pipeline picks up the new Browser test. ## Risks and notes - **Going beyond the `retry` label:** for non-blocked issues the button does more than the label does today. This is deliberate, following @SebastienDenooz's comment. The `retry` label behaviour itself is unchanged. - **Waiting statuses** (`waiting_info`, `waiting_validation`) are not re-runnable. Re-applying them would clear `waiting_reason` and `resume_phase`. Failed runs there are rare, since the human answer triggers the next run anyway. - **Transient failures with an automatic `retry_due` already queued:** the mailbox is `buffering`, so the button shows. A re-run makes it due now, and both events are consumed by a single planned run. That is acceptable. Hiding the button in that case is an alternative if preferred. - **Admission refusals** (budget, quota, runtime): no run is created and the event stays buffered, as with any trigger. The user sees the "Re-run requested" message rather than a new run. - **Authorization:** the button is owner-only through `ProjectPolicy::update` ("manage project"), while mailbox cancel and replay are member-level. This follows criterion 4. If members should re-run too, it is a one-line change in `RunPolicy::rerun`. - **Stale page:** the button is not live-refreshed. The server re-checks eligibility under a mailbox lock, which covers criterion 8. The implementation plan is ready for validation. Add the ai::validate_plan label to let me start. <!-- agent:key=46741bebc74d:kind=plan -->
Owner

Plan validated by @SebastienDenooz. Starting the development.

Plan validated by @SebastienDenooz. Starting the development. <!-- agent:key=bdba50e56c42:kind=plan_validated -->
Owner

The pipeline failure comes from the CI runner, not from this merge request, so no change to the repository can fix it.

Cause. Forgejo Actions runs #17 (push) and #18 (pull_request) both stopped at the Install dependencies step, while running npx playwright install --with-deps chromium. They never reached Pint, Larastan, the Vite build or Pest:

0 upgraded, 95 newly installed, 0 to remove and 5 not upgraded.
Need to get 94.5 MB of archives.
After this operation, 347 MB of additional disk space will be used.
E: You don't have enough free space in /var/cache/apt/archives/.
Failed to install browsers
Error: Installation process exited with code: 100

The Docker host behind the docker runner label is out of disk space. apt-get can't download the system libraries Chromium needs, so the job fails before any test runs. The earlier runs on main used the same workflow and passed, which fits a runner that has filled up since.

The code itself passes every check. I ran the same steps as the workflow on commit 52b5266:

  • vendor/bin/pint --test: passed
  • vendor/bin/phpstan analyse: passed, 0 errors
  • npm run build: passed
  • php artisan test against PostgreSQL, including the Playwright browser tests: all tests passed, no failures

What's needed. Someone with access to the runner host has to free up disk space, then re-run the pipeline. On that host, run docker system prune to remove dangling images, stopped containers and build cache, and clear old job workspaces and caches. No code change is required.

The pipeline failure comes from the CI runner, not from this merge request, so no change to the repository can fix it. **Cause.** Forgejo Actions runs #17 (push) and #18 (pull_request) both stopped at the *Install dependencies* step, while running `npx playwright install --with-deps chromium`. They never reached Pint, Larastan, the Vite build or Pest: ``` 0 upgraded, 95 newly installed, 0 to remove and 5 not upgraded. Need to get 94.5 MB of archives. After this operation, 347 MB of additional disk space will be used. E: You don't have enough free space in /var/cache/apt/archives/. Failed to install browsers Error: Installation process exited with code: 100 ``` The Docker host behind the `docker` runner label is out of disk space. `apt-get` can't download the system libraries Chromium needs, so the job fails before any test runs. The earlier runs on `main` used the same workflow and passed, which fits a runner that has filled up since. **The code itself passes every check.** I ran the same steps as the workflow on commit `52b5266`: - `vendor/bin/pint --test`: passed - `vendor/bin/phpstan analyse`: passed, 0 errors - `npm run build`: passed - `php artisan test` against PostgreSQL, including the Playwright browser tests: all tests passed, no failures **What's needed.** Someone with access to the runner host has to free up disk space, then re-run the pipeline. On that host, run `docker system prune` to remove dangling images, stopped containers and build cache, and clear old job workspaces and caches. No code change is required. <!-- agent:key=0e91fc650ed7:kind=comment -->
Owner

I am blocked during ci_fix: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry.

I am blocked during `ci_fix`: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry. <!-- agent:key=aff409d10cdc:kind=blocked_summary -->
Owner

Retrying ci_fix.

Retrying `ci_fix`. <!-- agent:key=f3bdad5cc868:kind=retry_resumed -->
Owner

The pipeline failed because the CI runner ran out of disk space, not because of the code.

Both failed runs on commit 52b5266 (run 17 from the push, run 18 from the pull request, around 13:52–13:58 UTC) stopped at the same step, before any test ran:

npx playwright install --with-deps chromium
...
Need to get 94.5 MB of archives.
After this operation, 347 MB of additional disk space will be used.
E: You don't have enough free space in /var/cache/apt/archives/.
Failed to install browsers
Error: Installation process exited with code: 100

The Forgejo runner did not have enough free disk space to install Chromium's system dependencies. Both runs were re-run on the same commit around 19:55–20:08 UTC and passed (CI / checks (push) and CI / checks (pull_request) are now green).

Running the checks locally on this branch, everything passes:

  • Pint and Larastan: no errors.
  • Unit and feature tests: 2111 passed.
  • Browser tests: 28 passed, including the new tests/Browser/Runs/RunRerunTest.php.
  • Full suite in one sequential php artisan test, as CI runs it: 2139 passed.

Nothing can fix this from the repository. The fix is to free disk space on the Forgejo runner with the docker label: prune old images, containers and volumes, clean the apt caches, and maybe add disk space.

**The pipeline failed because the CI runner ran out of disk space, not because of the code.** Both failed runs on commit `52b5266` (run 17 from the push, run 18 from the pull request, around 13:52–13:58 UTC) stopped at the same step, before any test ran: ``` npx playwright install --with-deps chromium ... Need to get 94.5 MB of archives. After this operation, 347 MB of additional disk space will be used. E: You don't have enough free space in /var/cache/apt/archives/. Failed to install browsers Error: Installation process exited with code: 100 ``` The Forgejo runner did not have enough free disk space to install Chromium's system dependencies. Both runs were re-run on the same commit around 19:55–20:08 UTC and passed (`CI / checks (push)` and `CI / checks (pull_request)` are now green). Running the checks locally on this branch, everything passes: - **Pint and Larastan:** no errors. - **Unit and feature tests:** 2111 passed. - **Browser tests:** 28 passed, including the new `tests/Browser/Runs/RunRerunTest.php`. - **Full suite in one sequential `php artisan test`, as CI runs it:** 2139 passed. Nothing can fix this from the repository. The fix is to free disk space on the Forgejo runner with the `docker` label: prune old images, containers and volumes, clean the apt caches, and maybe add disk space. <!-- agent:key=bbcdb116ee1e:kind=comment -->
Owner

I am blocked during ci_fix: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry.

I am blocked during `ci_fix`: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry. <!-- agent:key=cdc4aac25d19:kind=blocked_summary -->
Owner

Retrying ci_fix.

Retrying `ci_fix`. <!-- agent:key=e4097b5f70c0:kind=retry_resumed -->
Owner

I couldn't reproduce the pipeline failure, and no failure logs came with the request.

I ran the same steps as the CI jobs, in the same order, on this branch:

  • vendor/bin/pint --test: passed
  • vendor/bin/phpstan analyse: passed, 0 errors
  • npm run build (Node 24): passed
  • php artisan test against PostgreSQL 16, Playwright/Chromium included: 2139 / 2139 tests passed, including the new tests/Feature/Admin/Runs/RunRerunTest.php, tests/Feature/Lifecycle/RerunTest.php and tests/Browser/Runs/RunRerunTest.php

Since the code passes every check, the failure most likely comes from the runner: toolchain install (apt, NodeSource, Composer or Playwright downloads), the PostgreSQL service, or a transient browser/network issue. Please re-run the failed job. If it fails again, share the job log so the actual error can be looked at.

I couldn't reproduce the pipeline failure, and no failure logs came with the request. I ran the same steps as the CI jobs, in the same order, on this branch: - `vendor/bin/pint --test`: passed - `vendor/bin/phpstan analyse`: passed, 0 errors - `npm run build` (Node 24): passed - `php artisan test` against PostgreSQL 16, Playwright/Chromium included: **2139 / 2139 tests passed**, including the new `tests/Feature/Admin/Runs/RunRerunTest.php`, `tests/Feature/Lifecycle/RerunTest.php` and `tests/Browser/Runs/RunRerunTest.php` Since the code passes every check, the failure most likely comes from the runner: toolchain install (apt, NodeSource, Composer or Playwright downloads), the PostgreSQL service, or a transient browser/network issue. Please re-run the failed job. If it fails again, share the job log so the actual error can be looked at. <!-- agent:key=b56ca2a31964:kind=comment -->
Owner

I am blocked during ci_fix: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry.

I am blocked during `ci_fix`: the problem cannot be fixed from the repository (see my previous comment). Add the ai::retry label once the cause is fixed to let me retry. <!-- agent:key=ca86fe6ea49d:kind=blocked_summary -->
Alfred removed their assignment 2026-10-28 22:08:15 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Alchemic.Studio/Alfred#8
No description provided.