Skip to content

Retry failed test group cases - #77

Merged
joyzoursky merged 24 commits into
mainfrom
feat/test-group-retry-failed-cases
Aug 12, 2026
Merged

Retry failed test group cases#77
joyzoursky merged 24 commits into
mainfrom
feat/test-group-retry-failed-cases

Conversation

@joyzoursky

@joyzoursky joyzoursky commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Adds a user-selectable retry option to the Test Group config page. Retries run as extra passes after the whole group has finished; each attempt is its own TestRun row, so a failed attempt keeps its events, screenshots, and error trace.

Option Budget A retry round contains
No retry (default) — (identical to today)
Retry failed cases once 1 retry per case failed cases + cases cancelled behind them
Retry failed cases twice 2 retries per case same
Retry whole group once exactly 1 extra pass every case, whatever its status

Every policy shares one trigger: a retry round only starts if some case did not pass. A group that came back fully green never retries, whatever its policy. The policies differ only in scope — the failed-case ones re-run just the unresolved cases, while whole-group re-runs every case including the passing ones, since a sequential group's later cases may depend on state its earlier ones build.

Interaction with sequential runs and stop-on-failure

These were the two behaviours most at risk, and they fall out of one rule: a case is retry-eligible when its latest attempt is FAIL or CANCELLED.

Because a retry round replays the group's normal execution and failure rules over the planned cases, a stop-on-failure group resumes correctly rather than leaving its tail cancelled:

Round 0:  1 PASS   2 FAIL   3 CXL   4 CXL
Round 1:  plan [2,3,4] → 2 PASS, then 3 and 4 finally run

The budget is per case, not per group

A CANCELLED attempt never executed, so it spends no budget. A case skipped by stop-on-failure therefore keeps its full allowance when it first actually runs during a retry round:

Round 0:  1 PASS   2 FAIL(exec 1)   3 CXL(exec 0)
Round 1:  2 PASS,  3 FAIL(exec 1)          ← 3's first real execution
Round 2:  3 retried                        ← 3 still had its own retry

This makes the round count open-ended, so termination needs an explicit rule: under stop-on-failure, planning stops once an unresolved case has exhausted its budget. Cases behind a permanently broken one sit at executed = 0 forever, so without it the loop would replan them indefinitely. Two backstops sit behind that — a no-progress guard, and a retriesPerCase × caseCount ceiling that is provably unreachable and only exists so a bug settles the session instead of wedging it.

The main hazard this had to solve

Without a hold, the rollup settles the session the moment round 0's last member goes terminal — emitting the terminal event, posting the Slack summary, and releasing the hasActiveSession edit lock, all before any retry starts.

RunSession.retryPending holds a would-be-FAIL or would-be-CANCELLED session at RUNNING across that gap, because either outcome can still be retried. PASS settles immediately — no policy retries a green group. Every path that ends execution releases the hold — the orchestrator's finally, the stop button, and the stranded-session reaper — or a fully-terminal session would stay RUNNING forever and permanently block the group.

Two consequences worth reviewing:

  • The rollup now reduces to the latest attempt per case, or an earlier failed attempt would hold the session at FAIL after its retry passed. Same reduction applied to the Slack group summary and the run-session API, which previously counted memberRuns directly.
  • reapStrandedRunSessions had to learn about sessions with no active member at all — the exact shape of a group stranded between rounds by a crash.

Testing

  • 42 new unit tests; full suite 502 passing. The orchestrator tests use a stateful in-memory TestRun table rather than generic mocks, so a broken round loop cannot pass — including the per-case-budget and non-termination cases above.
  • Real Postgres: migrations applied to an empty DB; prisma migrate diff --exit-code reports no drift from schema.prisma; db:migrate:test (replay + idempotency + rollback simulation) passes.
  • 17 real-DB integration checks covering what the mocks couldn't: the queue-time policy snapshot, the rollup holding a fully-terminal FAIL session, the dispatcher's claim SQL refusing a retry attempt at sessionPosition = 0, the session settling PASS after a recovered retry despite failed rows remaining, and the reaper's awaiting-retry branch.
  • Every commit typechecks and passes the full suite standalone.

Notes for the reviewer

  • Locale line ceiling raised by 7 (check-config-i18n-guardrails.mjs) for the six selector strings plus the attempt badge, none reducible. That script asks for the locale modules to be split rather than bumped again; its note now says the split is overdue. Happy to do the split first if preferred.
  • npm run verify now passes end to end, audit included. 16 advisories appeared after this branch was cut, all against packages the overrides already pinned at what were then the patched versions; they are bumped past the new ranges here, with the CVE floors raised to match. This also surfaced js-yaml being imported by five first-party modules while declared in no manifest — it resolved only via a hoisted transitive copy — so it is now a declared apps/web dependency.
  • Not yet exercised end-to-end against a live flaky group with real target pages — that needs AI provider credentials. The retry state machine and all its queries are covered above, but a real browser group recovering on retry has not been observed.
  • Retries multiply AI-action usage on failing groups (whole-group retry roughly doubles it). Usage records are per-run so billing stays accurate; the UI hint calls this out.

🤖 Generated with Claude Code

joyzoursky and others added 24 commits August 11, 2026 16:33
A test group can now retry failed cases: once, twice, or by re-running the whole
group once. Retries run as extra passes after the entire group has finished, and
each attempt is its own TestRun row so a failed attempt keeps its events,
screenshots, and error trace.

The budget is per case and counts only attempts that reached PASS/FAIL, so a case
cancelled by stop-on-failure still gets its full allowance once it finally runs.
Under STOP, planning stops once an unresolved case has exhausted its budget:
cases behind a permanently broken one sit at executed = 0 forever, so without
that rule the round loop would never terminate.

A retry round replays the group's normal execution and failure rules over the
cases the plan selected, which is what makes a STOP group resume correctly — it
re-runs the failed case and then continues into the cases cancelled behind it
rather than leaving them cancelled. A whole-group retry clears the captured login
baselines so its login flows re-run; a failed-case retry keeps them, since login
flows that passed are not in the plan but their dependent cases still need them.

RunSession.retryPending solves the central problem: without it the rollup settles
the session the moment round 0's last member goes terminal, emitting the terminal
event, posting the Slack summary, and releasing the hasActiveSession edit lock
before any retry starts. It holds a would-be-FAIL session RUNNING through that
gap; PASS and CANCELLED still settle at once, since an all-pass group has nothing
to retry and a stopped one must not retry. Every path that ends execution
releases the hold or a fully-terminal session would stay RUNNING forever and
block the group. The stranded-session reaper also had to learn about sessions
with no active member at all, which is the shape of a group stranded between
rounds by a crash.

Schema, types, planning rules, session lifecycle, and the round loop land together
because a required TestGroupSummary.retryPolicy couples them — splitting further
would leave commits that do not compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A retried group has one member row per attempt, so counting memberRuns directly
double-counts a case and reports failures it has already recovered from. The Slack
group summary and the run-session API now reduce to the latest attempt per case;
the API also returns each case's attempt number and its earlier attempts so they
stay inspectable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four choices: no retry, retry failed cases once or twice, or retry the whole group
once. The run table keeps one row per case, showing the final attempt with an
attempt badge and links to the earlier ones.

Raises the locale line ceiling by 7 for the six selector strings plus the attempt
badge, none of which are reducible. That script asks for the locale modules to be
split rather than bumped again, so its note now says the split is overdue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_run_session returns retryPolicy, retryPending, and each member's attempt, so
an agent reading a retried group does not mistake repeated cases for duplicates or
treat a mid-retry FAIL as final. Tool names and input schemas are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the three ways this feature is easy to break: counting attempt rows
instead of reducing to the latest attempt, ending a session's execution without
releasing the retry hold, and adding an eligibility rule without a termination
argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry hold only guarded the FAIL branch of the rollup, but
WHOLE_GROUP_ONCE re-runs every case regardless of status. An all-passing
round 0 therefore settled PASS the moment its last member finished:
terminal event emitted, Slack summary posted, group edit lock released —
and only then did the retry pass start, flipping the session back to
RUNNING and settling it a second time.

Move the check ahead of the outcome branches so retryPending suppresses
any settle while it is set. Every path that ends execution already
releases the hold first (the orchestrator's finally, the stop button, the
stranded-session reaper), so a stopped or crashed session still settles
on its real outcome instead of waiting here.
The ceiling was tested before the round was planned, but it equals the
number of rounds a healthy run may legitimately use: 1 for
WHOLE_GROUP_ONCE, and retries * caseCount for the failed-case policies.
So every whole-group retry, and every single-case FAILED_ONCE group,
logged "hit the safety ceiling" on a perfectly normal run — a warning
whose whole purpose is to report a bug.

Plan the round first and return on an empty plan, so the ceiling is only
consulted when the loop still wants to run and reaching it really does
mean the plan is not converging.
A retried case has one TestRun row per attempt, so the two memberCount
readings that came straight from _count.memberRuns grew as a group
retried: a 5-case group reported 7 members on the group's session list
and through list_run_sessions once two cases were retried.

Add countSessionCases next to resolveLatestAttempts — the two together
now cover the whole invariant, one for statuses and one for totals — and
route both call sites through it. Also swap the run-session route's
per-row linear scan over the final attempts for a Set lookup.
npm audit reported 16 advisories that were not present when this branch
was cut. Every one lands on a package the overrides already pinned, with
a vulnerable range that now ends exactly at the pinned version — these
are fresh disclosures against versions previously considered patched, not
a regression from this branch (the lockfile is unchanged from main).

Raise each override past its new range, and the matching CVE floors so a
later resolution cannot drop back under them. Two nested override entries
shadowed their flat keys and had to move in lockstep, or the vulnerable
copies stayed nested:

  @midscene/core@1.9.2 > js-yaml   4.3.0  -> 4.3.1
  monaco-editor > dompurify        3.4.12 -> 3.4.13

Also declare js-yaml in apps/web. Five first-party modules import it —
including two API routes — but no manifest listed it; it resolved only
because a transitive copy happened to hoist to the root. Re-resolving
left the remaining copies nested and broke those imports, so the
undeclared dependency had to become a real one.

npm audit is clean and npm run verify passes end to end, including the
lockfile-floor gate that the audit failure previously short-circuited.
WHOLE_GROUP_ONCE returned every case unconditionally on the first retry
round, so a group that came back fully green still burned a second
complete pass — double the wall-clock and double the AI-action spend for
a run that had nothing wrong with it.

Require an unresolved case before any policy retries, which is what the
failed-case policies already did. The trigger is now uniform across
policies and only the scope differs: WHOLE_GROUP_ONCE re-runs every case
including the passing ones, because a sequential group's later cases may
depend on state its earlier ones build.

This also removes the reason the rollup held a would-be-PASS session at
RUNNING, so that hold goes back to covering only FAIL and CANCELLED. A
green group settles promptly again, and a crash straight after round 0 no
longer defers a correct PASS until the reaper's stale window elapses.
A mutation sweep over the retry code found the suite blind wherever state
was involved. Twelve deliberate defects, 502 tests: seven survived,
including every invariant the design notes call load-bearing — the rollup
settling despite retryPending, rolling up every attempt instead of the
latest, releaseSessionRetryHold doing nothing, and the stop button
skipping it. All five defects the suite did catch were in pure functions.

The cause was one gap. run-session-service.ts had no test file, and the
orchestrator harness mocks out recomputeRunSessionStatus and
releaseSessionRetryHold, so the composition of the hold, the
latest-attempt reduction and the rollup was never executed by anything —
which is exactly where both bugs found in review had lived.

Add three suites that drive the real functions:

- run-session-service: the recompute seam against an in-memory
  RunSession + TestRun pair, covering the hold, retried-case rollup,
  single terminal emission, and failActiveSessionMembers releasing the
  hold before it settles.
- cancel-run: the stop button releases the hold, and does so *before* the
  rollup — the ordering is what lets a group stopped between rounds
  settle now instead of waiting on the reaper.
- test-group-retry-runner: the loop driven directly through its injected
  runRound, reaching the no-progress backstop a real group cannot
  produce, and pinning how an attempt row is derived from its
  predecessors.
The prisma fake asserted nothing about how it was queried. It ignored
`orderBy` and hardcoded the descending attempt sort that
createRetryAttempts happens to want, so flipping that sort to ascending —
which makes the second retry round recreate an attempt number that
already exists — still passed. It also never looked at `runSessionId`, so
dropping the session scope from a query passed too.

Honour both instead of simulating them: apply the caller's own orderBy,
and filter on runSessionId. Seed every group with a decoy attempt of the
same case in another session at a far higher attempt number, so a query
that loses its scope numbers the next attempt from the decoy and fails
loudly rather than being coincidentally right.

Also make an over-run of a declared outcome script an error rather than a
silent PASS. That immediately exposed one test whose comment claimed a
case "exhausts after attempt 3" while attempt 3 was in fact defaulting to
PASS, so it asserted the case recovered when it meant to assert the case
stayed failed with its sibling's budget untouched.
The rollup guarded the transition into a terminal status atomically, but
the transition out of it was a bare update. Retry rounds are the first
thing that inserts new QUEUED members into a session whose members were
all terminal moments earlier, which makes that reachable: a stop landing
in the gap between rounds settles the session, then createRetryAttempts
inserts its rows, and the next recompute reopens a session that had
already reported its result.

The effect was worse than a flicker. Because the rollup reduces to the
latest attempt per case, the reopened session read as QUEUED — as if it
had never run — with completedAt cleared, and settling again emitted a
second terminal event whose status overwrote the first.

Guard the write the same way as the terminal one, so "a settled session
stays settled" holds at the write rather than by luck of ordering.
Two members lists on this page still described a retried group wrongly.

The login-flow prefix list, which tells a queued test which login flows it
is waiting on, was not reduced to the latest attempt. A whole-group retry
re-runs its login flows, so the same flow appeared twice and its
superseded failure was reported as the current reason the test had not
started. Reduce it like every other members list.

The copy-log listed session members as final attempts only, and marked
"<- this run" on an exact runId match. Opening an earlier attempt — which
the session page links to directly — therefore produced a bundle in which
the run being viewed was absent and its case was reported as whatever the
retry became, so a copied diagnostic for a failed attempt asserted the
case had passed. It now lists each superseded attempt, marks the viewed
run wherever it appears, and says outright when the run being viewed was
superseded and how that case ended.

Also cover the session GET, which this series reshaped without tests.
Attempt numbers are assigned in application code as max(attempt) + 1 per
case, with nothing stopping a retry from reusing one. A duplicate would
fail quietly rather than loudly: the case's executed count would double,
cutting its retry budget below what the policy promises, and the
latest-attempt reduction would tie-break on whatever order the rows came
back in. Add the unique index so such an insert is rejected instead.

Folded into this branch's existing migration rather than added as a second
one, keeping the change series at a single migration.

Standalone runs are unaffected: their runSessionId is null, which Postgres
treats as distinct, so two runs of one case still coexist. Verified on a
scratch database — attempts 1 and 2 of a case insert fine, a second
attempt 2 is rejected by name, and two null-session runs of the same case
both succeed. `migrate diff` reports no drift from schema.prisma, and the
replay/idempotency/rollback verifier passes.
stop_all_runs settled each active row with a per-run cancel, which never
reaches the session's in-process driver: the status watcher only aborts
that member's own controller, never the session's. A group with a retry
policy therefore kept going after the stop, and because a cancelled
attempt spends no retry budget, every case it had just cancelled came back
eligible with a full allowance — so stopping a retrying group could spend
more AI actions than it saved.

Route runs that belong to a session through cancelActiveRunSession, which
aborts the driver, releases the retry hold, and rolls the session up.
Standalone runs keep the per-run path. cancelActiveRunSession takes an
optional reason so MCP stops stay attributable rather than being recorded
as a UI stop.

stop_all_queues deliberately keeps cancelling row by row: draining the
queue must not kill a running test. The next commit stops its cancelled
cases from being retried.
Retry planning treated every CANCELLED attempt as unresolved, which is
right for a case the group skipped behind a failure but wrong for one a
person stopped: since a cancelled attempt spends no budget, stopping a run
handed that case its whole allowance back. Reachable from the UI stop
button and from all three MCP tools that cancel runs — stop_all_runs,
stop_all_queues, and the cancel update_test_case performs to apply an
edit, which could then re-run the case against the edited config.

The cancellation reason already carries the distinction, so classify on it:
USER_SINGLE, USER_GROUP, MCP and MCP_FOR_UPDATE resolve the case, while
EARLIER_CASE_FAILED and LOGIN_FLOW_FAILED stay retryable so stop-on-failure
still resumes. An unrecognized reason stays retryable too, so an unmapped
string can never quietly weaken that.
get_run_session returned every attempt row, and in the gap between retry
rounds all of them are terminal while the session is not — so the obvious
reading of "all members settled" said a retrying group had finished. Return
one entry per case at its latest attempt instead, with superseded ones and
their reason codes under previousAttempts, matching what the web session
route already returns. That also makes members.length agree with
list_run_sessions' memberCount again.

get_test_run now reports the run's own attempt, so an agent handed a runId
from a failed group can tell a superseded attempt from the current one.

The tool description no longer asks the caller to reduce attempts itself;
it says to judge completion by the session status. Manifest snapshot
updated deliberately — tool names and input schemas are unchanged.
Stopping was session-wide from the UI and the single-run HTTP cancel — that
route already escalates any member to cancelActiveRunSession — but two MCP
paths still settled rows one at a time, so a stop left the group running:

- stop_all_queues cancelled queued members individually. A queued member
  cannot be drained out of a live session, because the session's driver
  decides what runs next and a retry policy re-queues what was cancelled.
- update_test_case with cancel_and_save cancelled only this case's run, so
  its group carried on — and with a retry policy would re-run this very
  case against the edit being saved.

Route both through the same session-aware helper, so all five stop paths
now behave identically: a run that belongs to a session stops the session,
and standalone runs are still cancelled individually.

Stopping a queued member therefore also stops that group's running member.
That is the intent, but the count would otherwise be quietly larger than
what the caller asked for, so the helper reports the extra members in
sessionMembersAlsoCancelled and all three tools surface it.
Stopping is session-wide, so a caller naming one queued case can end a
whole test group including the case running right now. That is the intent
but it is not visible in the request, so stop_all_runs and stop_all_queues
now refuse until the caller answers.

When any matched run belongs to a session they return
SESSION_STOP_CONFIRMATION_REQUIRED, naming the affected test groups and
reporting how many members would settle versus how many were asked for,
and cancel nothing. The caller re-calls with activeSessionResolution:
stop_sessions to end those groups too, or only_standalone to leave them
running and stop just the runs outside a session, which come back listed
in sessionsLeftRunning. Runs outside a session need no confirmation, so
stopping ad-hoc runs stays one call.

The shape mirrors update_test_case's ACTIVE_RUN_CONFIRMATION_REQUIRED so an
agent meets one confirmation pattern rather than two. update_test_case does
not re-confirm: choosing cancel_and_save is already the answer.

Manifest snapshot updated deliberately — this adds an optional input to two
tools, and an existing caller now meets the confirmation instead of
silently stopping more than it named.

Also bring the agent skills and docs in line:
- skytest gained the stop-confirmation rule and a retry section; it had no
  mention of retries at all, so an agent watching a group could read the
  gap between rounds as completion.
- skytest-fix now checks a run is still the current attempt before
  diagnosing it, so a flake a retry already absorbed is not "fixed", and
  knows not to stop runs to investigate.
- review skill pointed at apps/web/src/i18n/messages.ts, which has not
  existed since the locales were split into modules.
The team does not read documentation in this repo; the actual readers are
coding agents, which CLAUDE.md routes to specific files. Keep that set and
drop the rest.

Removed the six operator self-hosting runbooks, five maintainer docs that
were human process rather than agent reference (AI provider troubleshooting,
CLI Homebrew release, dependency lifecycle policy, performance
observability, repository hygiene), and the docs index, whose job CLAUDE.md
already does. Ten maintainer docs remain: the runtime invariants, contracts
and diagnostics an agent needs to change this code safely.

Nothing was deleted while still referenced. Inbound links were rewritten in
CLAUDE.md, both READMEs, apps/macos-runner/README.md, three remaining
maintainer docs, and three development skills — setup pointers now name the
make targets and the surviving maintainer docs instead. All 27 relative
markdown links across tracked files resolve, and no tracked file mentions
docs/operators or docs/README any more.

Note for whoever self-hosts from this repo: the operator setup path is now
only `make bootstrap` / `make dev` plus infra/README.md. If external
self-hosters need those runbooks, restore them from this commit rather than
rewriting them.
Both stop tools searched for runs in an active status. The retry hold makes
a session live with every member terminal, so in the gap between rounds
there is no active run to find: stop_all_runs reported
requestedActiveRuns: 0, cancelled nothing, showed no confirmation, and the
retry loop went on to create the next round. An explicit stop was a silent
no-op precisely while a group was running.

This is the same class as the settled-session resurrection fixed earlier —
the hold created a state ("live with nothing active") that surrounding code
had no reason to expect. Before retries, no active runs did imply no live
session, because the rollup could not hold an all-terminal session open.

Stop now starts from live sessions as well as active runs:
findLiveSessionIdsForStop returns the project's non-terminal sessions, the
confirmation gate describes them like any other affected session, and
cancelRunsForStop stops them even when no requested run belongs to them.
Session ids are deduped, so a session reached both ways is cancelled once.

Recorded as a footgun in the maintenance guide: anything that stops, sweeps
or reaps must consider non-terminal RunSession rows, not just active runs.
@joyzoursky
joyzoursky merged commit 39daa50 into main Aug 12, 2026
5 checks passed
@joyzoursky
joyzoursky deleted the feat/test-group-retry-failed-cases branch August 12, 2026 09:51
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