Skip to content

ci: browser cycle (chromium) is flaky — the annotator's auto-start effects race and surface INVALID_TRANSITION #319

Description

@JArmandoAnaya

Symptom

browser cycle (chromium) — the full-cycle Playwright run against a real server — fails intermittently at cycle/cycle.spec.ts:321:

Error: expect(locator).toContainText(expected) failed
  Expected substring: "Saved"
  Received string:    "This has already moved on — reload to see where it is now."

Observed twice on 2026-08-04/05:

Diagnosis

The rendered string is refusalProse for INVALID_TRANSITION. That code cannot come from the save itself: AnnotationService.add refuses a closed batch with BatchNotInAnnotation, never InvalidTransition.

It comes from the auto-start effects. AnnotationPage's SaveState renders save.error and falls back to openingRefusal — the state that catches a refused POST /batches/{id}/start or POST /jobs/{id}/start fired on open (#300, and the StrictMode ref-guard added there). So the sequence is: the batch or the job was already started when the effect sent the transition, the kernel answered INVALID_TRANSITION, and the refusal landed in the same slot the save reports through — where the spec was waiting for "Saved".

Likely races:

  • the cycle spec enters the annotator through a tile, and the batch may already be in_annotation from an earlier step;
  • jobState is read from a query that may still be serving pending when the effect fires, so the guard's jobState === "pending" check passes against a stale value.

Both are timing-dependent, which matches the intermittency and matches it appearing on a loaded CI runner rather than locally.

Why it matters more than an ordinary flake

The real-server cycle suite was three separate times the only suite to catch a regression during the 2026-08 run (#306, #308, #309 — see #314). A suite with that hit rate must be trustworthy, because the reflex for a flaky required check is to stop reading it.

It is also a required check on the main ruleset, so a flake blocks merges until somebody re-runs it by hand.

Suggested direction

Not "add a retry". Two candidates worth looking at first:

  1. Do not surface an opening refusal that the page has already recovered from. If the batch is in_annotation and the job is in_progress by the time the answer arrives, an INVALID_TRANSITION from a start effect is already-done, not a failure — the page is in the state the effect wanted. Distinguishing "refused because it was already true" from "refused because it may not" would clear the symptom honestly rather than hiding it.
  2. Guard the effects on the freshest state, not on a possibly-stale query result — the ref-guard from fix(ui-core): opening a job in an approved batch starts the batch too #300 prevents a double send, not a send against a stale read.

Whichever is chosen, the cycle spec's assertion at :321 stays as it is: waiting for "Saved" after a save is the right thing to assert.

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingciCI, tooling, repo automationui-corefrontend/ui-core work

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions