Skip to content

fix(execution): cancelling twice must stay idempotent - #267

Merged
dovvnloading merged 1 commit into
mainfrom
fix/p0-followup-corrections
Sep 12, 2026
Merged

dovvnloading merged 1 commit into
mainfrom
fix/p0-followup-corrections

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

The problem

Self-review of #266 found a regression that PR introduced, plus three smaller
things it got wrong. CI was green on all seven jobs, so none of this was caught
by the existing suite.

The regression. #266 made cancelling a one-way state in the execution
store. It left cancelling unreachable from itself -- but request_cancel()
writes status="cancelling", so a second Stop raised
ExecutionTransitionConflict. routers/execution.py maps that whole error
family to 404, so pressing Stop twice told the user "Execution job not
found"
. Clicking Stop twice is an ordinary thing to do when a local model is
slow to react.

No existing test double-cancels, which is exactly why it slipped through.
Reproduced before the fix:

first  cancel: cancelling
second cancel: ExecutionTransitionConflict: Execution job is cancelling and cannot become cancelling.

What changed

_CANCELLING_EXITS gains cancelling, so a repeated cancel is idempotent
while nothing non-terminal may still follow a committed cancellation. The new
test fails against the previous frozenset and passes with it, and it re-asserts
the original invariant (a running write is still refused) so the fix cannot
be widened by accident later.

Three smaller corrections from the same review:

  1. An impure React initialiser. fix: nine correctness defects in cancellation, consent, time, and startup #266 wrote the handoff secret to
    sessionStorage from inside readLauncherCredentials, which runs from a
    useState initialiser. The comment immediately below that function explains
    why the URL scrub was moved out of the initialiser: React documents
    initialisers as pure and StrictMode double-invokes them to surface impure
    ones. The write now happens in the effect that already performs the scrub,
    and the docstring no longer claims a purity it did not have.

  2. An overstated claim. fix: nine correctness defects in cancellation, consent, time, and startup #266's message said a Stop pressed during
    translation returns the finished answer. It did not: the cancellation check
    was skipped only when translation had also failed, and the API runner
    discards the result whenever the cancel event is set, so nothing
    user-visible changed either way. The check is unconditional again and the
    comment now says what actually happens, and where keeping finished work
    across a cancellation would genuinely have to be fixed.

  3. A trap left behind. The legacy JSON migration's fallback timestamp was
    still naive, and storage._utc_now() survived with no callers -- ready to
    quietly reintroduce the zone-less writes fix: nine correctness defects in cancellation, consent, time, and startup #266 removed. Both are gone; read
    normalisation already covered the display side.

Verification

./scripts/check.ps1         All 10 checks passed in 108.6s
python -m pytest -q         950 passed
python -m mypy              Success: no issues found in 79 source files

The idempotence test was confirmed by reverting the one-line fix and watching
it fail, then restoring it.

Risk

Narrower than #266. The store invariant is relaxed in exactly one direction
(cancelling to itself); recovery writes its cancelled rows through direct SQL
rather than transition(), so it was never affected by either version. Rollback
is a revert of this single commit, which restores #266's behaviour including the
double-cancel defect.

🤖 Generated with Claude Code

Self-review of #266 found a regression it introduced. Making "cancelling"
a one-way state left it unreachable from itself, but request_cancel()
writes status="cancelling" -- so a second Stop, or a recovery pass
re-requesting a cancel, raised ExecutionTransitionConflict. The execution
router maps that to 404, so pressing Stop twice told the user "Execution
job not found". No existing test double-cancels, which is why CI stayed
green.

Allow cancelling -> cancelling while keeping the invariant that nothing
non-terminal may follow it, and pin the idempotence with a test that fails
against the previous frozenset.

Three smaller corrections from the same review:

- The read of the launcher credentials was writing the handoff secret to
  sessionStorage from inside a useState initialiser -- which the comment
  immediately below it explains must stay pure, because StrictMode
  double-invokes initialisers. Moved the write into the effect that
  already scrubs the URL, where the other side effect lives.

- #266's commit message claimed a Stop pressed during translation returns
  the finished answer. It does not: the check was skipped only when
  translation had also failed, and the API runner discards the result
  whenever the cancel event is set, so nothing user-visible changed
  either way. Restored the unconditional check and replaced the comment
  with what actually happens, and where keeping work across a cancellation
  would have to be fixed instead.

- The legacy JSON migration's fallback timestamp was still naive, and
  storage._utc_now() was left behind with no callers -- a trap that would
  quietly reintroduce the zone-less writes #266 removed. Both gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit 10d2233 into main Sep 12, 2026
7 checks passed
@dovvnloading
dovvnloading deleted the fix/p0-followup-corrections branch September 12, 2026 11:54
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