feat(sessions): give every one-way transition a way back (#416) #449

Merged
claude-bot merged 2 commits from fix/416-reversible-transitions into main 2026-08-30 00:08:31 +00:00
Contributor

Closes the five stuck states in #416 that had no existing owner. Two commits.

Commit 1 — reversing four one-way transitions

Four states could be entered and never left. No endpoint reversed any of them, so a mis-click or a dead worker left editing the database by hand as the only repair:

  • Completed and cancelled sessions are dead ends. confirm_session accepts only proposed, and reopen_session_for_voting excludes both — nothing anywhere assigns a status back. "Mark completed" fires the notes harvest and the Discord fan-out irreversibly.
  • Approved summaries. Approving is what reposts the summary to Discord, and it 409s the second time. A GM who approved and then spotted an error could fix the text in the app forever without the correction ever reaching the channel.
  • Drafts stuck at generating. iterate_draft refuses a generating draft and only one draft may exist per entry, so the single available exit was discard_draft — a hard db.delete taking the GM's inline edits, iteration_count, last_directive and the LLM output with it. Trading away a session's work to unstick a status field is not a recovery path.

Adds POST /sessions/{id}/uncomplete, /uncancel, /unapprove, and POST /campaigns/{id}/lore/{entry}/drafts/{draft}/reset — each with the UI affordance to reach it, all low-emphasis: these are recovery paths, not normal steps.

Both session reversals are audited, because they rewrite a state players and Discord have already seen. Un-cancelling reconstructs the prior status from confirmed_time rather than storing it — nothing recorded it, and a column to hold a guess is not worth a migration.

The draft reset only marks the draft failed. That is the whole fix: iterate, approve and discard are all already reachable from failed, so the GM regains every option by choice rather than by force, with their work intact. A 15-minute floor keeps a slow LLM call from being mistaken for a dead one.

What deliberately stays done

Reversal is not a rewrite of history, and three tests pin that so nobody later "improves" it into one:

  • Discord messages are not recalled. Completing again posts a second notice — honest, and better than a silent divergence between the channel and the app.
  • Harvested shelf notes stay merged into LoreEntry.gm_notes. They are plain text a GM may have edited since, so there is no safe automatic unmerge. harvest_notes_on_complete is idempotent per card, so re-completing will not double-append.
  • Withdrawing an approval does not un-trash the audio. Approval may move the file to trash with the 7-day grace running from that moment (#427). If withdrawal pulled it back, an approve/withdraw cycle would reset the window every time — turning a bounded retention guarantee into an unbounded one, which is exactly what that window exists to prevent.

Commit 2 — the stalled lore run, and the double-run behind it

The fifth state, plus a defect the issue does not mention. These had to ship together.

The UI hid the re-run action for all three in-progress states, so a run whose worker died left the session showing "Extracting lore candidates…" with nothing to click. The obvious fix is to stop hiding it — except the endpoint behind that button guarded on pending only. extracting and matching fell straight through, committing pending over a live run and queueing a second extraction over the same transcript: duplicate proposals, doubled tokens. Nothing had hit it because the button was hidden — which is precisely the rule being relaxed. Relaxing the UI without closing the guard would have converted a latent double-run into a one-click one.

Adds sessions.lore_generation_progress_at (migration a8b0c1d2e3f4), stamped on enqueue and at each phase transition. updated_at could not serve: it carries onupdate=func.now(), so any unrelated write to the session — a title edit, an attendance change — resets it, and it is not exposed in SessionResponse anyway.

Named for progress, not start, deliberately: a long but healthy run keeps proving it is alive rather than ageing into "stuck" and inviting the very duplicate this prevents. A start-only stamp would force the threshold above the slowest legitimate run, which is the multi-hour window we are trying to remove. NULL reads as "unknown, leave alone", so rows predating the column never trigger a spurious retry offer — #398's watchdog still frees them.

Verification

Mutation-checked throughout:

  • Implementation stashed: 13 of 14 transition tests fail. The 14th passed vacuously — it asserted "the audio is still trashed", which holds just as well when the endpoint 404s — so it was tightened to assert the withdrawal returned 200 first. All 14 now fail.
  • Old pending-only guard restored: both concurrency tests fail.
  • fresh pinned true: the supersede test fails.

The migration was applied over the full chain against a scratch database and downgraded back off, both clean.

Frontend tests were run in a container (there is no local node). That caught a real bug reading alone had missed: the renderPage helper gained a sessionsPage parameter but still hardcoded an empty list internally, so the new session row never reached the component.

1,439 backend tests pass (was 1,409). 449 frontend (was 437). Lint and format clean at CI's pinned ruff 0.4.4.

#416 after this

Five of five unowned states are addressed. The Workbench "stuck pending" item needed no new work — GenerationHistory.jsx never filters by status, so pending and failed rows already render with a badge and a working re-open button, and #398's watchdog now gives them a terminal state within the hour. Reasoning recorded on the issue; happy to reopen if you read it differently.

Of the six states cross-referenced to other issues, three have shipped (#398, #399, and the audio half here); #413, #411 and the two bot/reconciliation issues remain open and are tracked there.

🤖 Generated with Claude Code

Closes the five stuck states in #416 that had **no existing owner**. Two commits. ## Commit 1 — reversing four one-way transitions Four states could be entered and never left. No endpoint reversed any of them, so a mis-click or a dead worker left editing the database by hand as the only repair: - **Completed** and **cancelled** sessions are dead ends. `confirm_session` accepts only `proposed`, and `reopen_session_for_voting` excludes both — nothing anywhere assigns a status back. "Mark completed" fires the notes harvest and the Discord fan-out irreversibly. - **Approved summaries.** Approving is what reposts the summary to Discord, and it 409s the second time. A GM who approved and then spotted an error could fix the text in the app forever without the correction ever reaching the channel. - **Drafts stuck at `generating`.** `iterate_draft` refuses a generating draft and only one draft may exist per entry, so the single available exit was `discard_draft` — a hard `db.delete` taking the GM's inline edits, `iteration_count`, `last_directive` and the LLM output with it. Trading away a session's work to unstick a status field is not a recovery path. Adds `POST /sessions/{id}/uncomplete`, `/uncancel`, `/unapprove`, and `POST /campaigns/{id}/lore/{entry}/drafts/{draft}/reset` — each with the UI affordance to reach it, all low-emphasis: these are recovery paths, not normal steps. Both session reversals are **audited**, because they rewrite a state players and Discord have already seen. Un-cancelling reconstructs the prior status from `confirmed_time` rather than storing it — nothing recorded it, and a column to hold a guess is not worth a migration. The draft reset only marks the draft `failed`. That is the whole fix: iterate, approve and discard are all already reachable from `failed`, so the GM regains every option **by choice rather than by force**, with their work intact. A 15-minute floor keeps a slow LLM call from being mistaken for a dead one. ### What deliberately stays done Reversal is not a rewrite of history, and three tests pin that so nobody later "improves" it into one: - **Discord messages are not recalled.** Completing again posts a second notice — honest, and better than a silent divergence between the channel and the app. - **Harvested shelf notes stay merged** into `LoreEntry.gm_notes`. They are plain text a GM may have edited since, so there is no safe automatic unmerge. `harvest_notes_on_complete` is idempotent per card, so re-completing will not double-append. - **Withdrawing an approval does not un-trash the audio.** Approval may move the file to trash with the 7-day grace running from that moment (#427). If withdrawal pulled it back, an approve/withdraw cycle would reset the window every time — turning a bounded retention guarantee into an unbounded one, which is exactly what that window exists to prevent. ## Commit 2 — the stalled lore run, and the double-run behind it The fifth state, plus a defect the issue does not mention. **These had to ship together.** The UI hid the re-run action for all three in-progress states, so a run whose worker died left the session showing "Extracting lore candidates…" with nothing to click. The obvious fix is to stop hiding it — except the endpoint behind that button guarded on `pending` **only**. `extracting` and `matching` fell straight through, committing `pending` over a live run and queueing a **second extraction over the same transcript**: duplicate proposals, doubled tokens. Nothing had hit it because the button was hidden — which is precisely the rule being relaxed. Relaxing the UI without closing the guard would have converted a latent double-run into a one-click one. Adds `sessions.lore_generation_progress_at` (migration `a8b0c1d2e3f4`), stamped on enqueue and at each phase transition. `updated_at` could not serve: it carries `onupdate=func.now()`, so any unrelated write to the session — a title edit, an attendance change — resets it, and it is not exposed in `SessionResponse` anyway. Named for **progress**, not start, deliberately: a long but healthy run keeps proving it is alive rather than ageing into "stuck" and inviting the very duplicate this prevents. A start-only stamp would force the threshold above the slowest legitimate run, which is the multi-hour window we are trying to remove. NULL reads as "unknown, leave alone", so rows predating the column never trigger a spurious retry offer — #398's watchdog still frees them. ## Verification Mutation-checked throughout: - Implementation stashed: **13 of 14** transition tests fail. The 14th passed vacuously — it asserted "the audio is still trashed", which holds just as well when the endpoint 404s — so it was tightened to assert the withdrawal returned 200 first. All 14 now fail. - Old `pending`-only guard restored: both concurrency tests fail. - `fresh` pinned true: the supersede test fails. The migration was applied over the full chain against a scratch database and downgraded back off, both clean. Frontend tests were run in a container (there is no local node). That caught a real bug reading alone had missed: the `renderPage` helper gained a `sessionsPage` parameter but still hardcoded an empty list internally, so the new session row never reached the component. **1,439 backend tests pass** (was 1,409). **449 frontend** (was 437). Lint and format clean at CI's pinned ruff 0.4.4. ## #416 after this Five of five unowned states are addressed. The Workbench "stuck pending" item needed no new work — `GenerationHistory.jsx` never filters by status, so `pending` and `failed` rows already render with a badge and a working re-open button, and #398's watchdog now gives them a terminal state within the hour. Reasoning recorded on the issue; happy to reopen if you read it differently. Of the six states cross-referenced to other issues, three have shipped (#398, #399, and the audio half here); #413, #411 and the two bot/reconciliation issues remain open and are tracked there. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(sessions): give every one-way transition a way back (#416)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 50s
CI / Backend lint (ruff) (pull_request) Successful in 41s
CI / Bot/backend version sync (pull_request) Successful in 35s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m34s
CI / Docker image build (pull_request) Successful in 56s
CI / Bot tests and audit (pull_request) Successful in 1m19s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m25s
65f920d4ef
Four states could be entered and never left: a completed session, a
cancelled one, an approved summary, and a draft stuck generating. None had
a reverse action, so a mis-click or a dead worker left an UPDATE against
the database as the only repair.

Adds uncomplete, uncancel, unapprove and a draft reset, each with the UI
affordance to reach it. The session reversals are audited — they rewrite a
state players and Discord have already seen. Un-cancelling reconstructs the
prior status from confirmed_time rather than storing it, since nothing
recorded it and a column for a guess is not worth a migration.

The draft reset only marks the draft failed. That is enough: iterate,
approve and discard are all reachable from failed, and unlike discarding it
keeps the GM's inline edits and every round of iteration. Guarded by a
15-minute floor so a slow LLM call is not mistaken for a dead one.

Side effects stay done, deliberately and with tests pinning it: Discord
messages are not recalled, harvested shelf notes stay merged into gm_notes
(a GM may have edited them since), and withdrawing an approval does not
pull audio back out of the trash — that grace window is bounded by design
(#427) and an approve/withdraw cycle must not be able to extend it.

Mutation-checked: all 14 new backend tests fail against the unfixed code.
1,423 backend and 446 frontend tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(lore): let a stalled run be retried, and stop it double-running (#416)
All checks were successful
CI / Docker image build (pull_request) Successful in 34s
CI / Bot/backend version sync (pull_request) Successful in 39s
CI / Backend lint (ruff) (pull_request) Successful in 40s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 49s
CI / Bot tests and audit (pull_request) Successful in 1m15s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m20s
CI / Backend migration, tests, and audit (pull_request) Successful in 4m56s
32c8f54d21
Two defects that had to be fixed together. The UI hid the re-run action for
all three in-progress states, so a run killed mid-flight was unreachable
from the GM's chair. But the endpoint behind that button guarded only on
`pending` — `extracting` and `matching` fell straight through and queued a
second extraction over the same transcript. Relaxing the UI without closing
the guard would have turned a latent double-run into a one-click one.

Adds sessions.lore_generation_progress_at (a8b0c1d2e3f4), stamped on
enqueue and at each phase transition. `updated_at` could not serve: it
carries onupdate=func.now(), so any unrelated write to the session resets
it, and it is not exposed in SessionResponse anyway.

Named for progress rather than start on purpose — a long healthy run keeps
proving it is alive, instead of ageing into "stuck" and inviting the
duplicate this change exists to prevent. A start-only stamp would have
forced the threshold above the slowest legitimate run.

NULL reads as "unknown, leave alone", so rows predating the column never
trigger a spurious retry offer; the watchdog still frees them.

Mutation-checked both halves: the old pending-only guard fails the two
concurrency tests, and pinning `fresh` true fails the supersede test.
1,439 backend and 34 SessionDetail tests pass. Migration verified to apply
and downgrade cleanly over the full chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/416-reversible-transitions 2026-08-30 00:08:33 +00:00
Sign in to join this conversation.
No description provided.