Skip to content

test(missions): wait for the missed passage admission before Stop - #935

Merged
pascalandr merged 1 commit into
devfrom
fix/recurrence-control-conflict-test
Oct 13, 2026
Merged

pascalandr merged 1 commit into
devfrom
fix/recurrence-control-conflict-test

Conversation

@pascalandr

Copy link
Copy Markdown
Contributor

Summary

recurring-day.e2e.test.ts test E ("Pause cancels wakeups and future admissions; Stop is terminal") fails intermittently on CI with stop: {"statusCode":500,"error":"Internal Server Error","message":"Recurrence control conflict"}. It blocked the tests job on #925, #931 and feat/provider-account-rotation (run 38279531443).

Cause: after the second Play, the schedule admits the passage missed while it was paused, through background writes (pending passage, start, accepted admission: revisions 5 → 8). The fixture's control() reads the expected revision from a snapshot after a short real-time flush() (4 × 1 ms). On a slower runner the admission writes land after that snapshot, so Stop's compare-and-set meets a newer revision and is refused.

Reproduced locally by shortening flush() to a single setImmediate: the unchanged test fails with the exact CI error.

Fix (test only): wait, through the fixture's bounded until(), for the accepted admission before Stop. Stop is modelled as a later human action at the settled revision rather than a race with the admission. Product behaviour is unchanged.

Validation

  • Shortened flush: unchanged test fails with the CI error; fixed test passes.
  • Full recurring-day.e2e.test.ts with the normal flush: 22/22.

Observation (not changed here)

A real Stop that races the same background admission is also refused by the optimistic revision check, and the route reports that refusal as a 500. Whether conflicts should map to 409 and how the UI retries is a separate product question.

recurring-day.e2e test E failed intermittently on CI with 'stop: 500 Recurrence control conflict' (seen on #925, #931 and feat/provider-account-rotation).

After the second Play, the schedule admits the passage missed while paused through background writes (pending passage, start, accepted admission: revisions 5 to 8). The fixture's control() reads the expected revision from a snapshot after a short real-time flush; on a slower runner the admission writes land after that snapshot, so Stop's compare-and-set meets a newer revision and is refused. Reproduced locally by shortening the flush to one setImmediate: the unchanged test fails with the exact CI error.

The test now waits, through the fixture's bounded until(), for the accepted admission before Stop, modelling Stop as a later human action at the settled revision. With the shortened flush it passes; the full recurring-day file passes (22/22). Product behaviour is unchanged.

@pascalandr pascalandr left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Autonomous review: 0 findings

Reviewed head 130fcfa1e030d91ab2183289e2b45d725ca82da3 against dev (a4c12dab; the only file difference is this PR's three lines).

Correctness of the fix

  • After Play, the schedule Job's wake runs MissionRecurrenceRunner.tick: reserve → native admission → recordAdmission. recordAdmission is the last revision-bearing write of that wake.
  • The post-wake clearRecurrenceWakeError writes a display-only sibling key (native-recurrence-wake-status.ts). That key is outside the schedule document's revision/CAS, so it cannot conflict with Stop.
  • The next wake only follows virtual time or the settled-event debounce, which is also a virtual sleep. Neither advances inside until(), so waiting for pending.admission reaches the settled revision that control("stop") then reads.
  • until() is bounded (5 s real time, virtual clock untouched) and fails with an explicit message. Stop still exercises the terminal path with an admitted pending passage, as before on fast runners. Product code is unchanged.

Validation (isolated temporary worktree, no shared daemon)

  • recurring-day.e2e.test.ts with the normal flush: 22/22 pass.
  • flush() temporarily shortened to one setImmediate (not committed):
    • without this change: test E fails 3/3 with the exact CI error, stop: {"statusCode":500,…,"message":"Recurrence control conflict"}
    • with this change: 3/3 pass
  • Server typecheck passes.

CI note

The red tests job on this run is two tests/browser/panel-extensions.test.ts API2 timeouts. The same two failures appear on unrelated branches (for example feat/git-init-action, run 38330596020), so they are not caused by this PR. In this run, test E itself passed.

The 409-vs-500 mapping for genuine Stop/admission races, noted in the description, remains a separate product question.

@pascalandr
pascalandr merged commit 8552a8f into dev Oct 13, 2026
13 of 14 checks passed
@pascalandr
pascalandr deleted the fix/recurrence-control-conflict-test branch October 13, 2026 10:02
@pascalandr

Copy link
Copy Markdown
Contributor Author

Round 1 corrections pushed (6cb30c2)

  • until() unbounded: the deadline now uses performance.now() (Date.now is the fixture's virtual clock).
  • Admission wait could hang: test E now waits with quiescent() until every running Job is parked in a virtual sleep, so Stop follows a settled revision whatever the admission outcome.

Validated with the shortened flush and with Play's control record delayed by 8 and 25 microtasks (the interleavings that hung the previous version); full file 22/22. Out of scope and recorded: the same slow-runner fragility in other tests of the file, the 500 for a racing human Stop, and Play/Job first-wake interleaving delaying a missed passage.

pascalandr added a commit that referenced this pull request Oct 13, 2026
…s before Stop (#939)

Follow-up to #935, which merged before its Gatekeeper round 1
corrections were pushed.

## Problem
- `RecurringDayFixture.until()` computed its deadline with `Date.now`,
which the fixture replaces with its virtual clock, so a wait that never
succeeds never timed out.
- Test E waited for `pending.admission` after the second Play. When
Play's completion record lands just after the schedule Job's first wake,
the admission is deferred and that wait hangs.

## Fix
- `until()` measures its deadline with `performance.now()`.
- New `quiescent()` waits until every running Job is parked in a virtual
sleep. Test E calls it before Stop, so Stop follows a settled revision
whatever the admission outcome (admitted, revision conflict, or reserved
but not admitted).

## Validation
- Test E passes with the fixture flush shortened to one `setImmediate`,
and with Play's control record delayed by 0 to 40 microtasks, covering
all three admission outcomes.
- The full `recurring-day.e2e.test.ts` file passes 22/22.
- Gatekeeper round 2 on these exact changes: ZERO FINDINGS.

## Out of scope, recorded
- Other tests in the file remain sensitive to a slow flush.
- A Stop that hits a revision conflict returns 500 rather than 409.
- Play's completion record can collide with its Job's first wake and
delay a missed passage.
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