[Bot] Stop the startup sweep from deleting recordings it told the user were saved #399
Closed
opened 2026-08-25 20:44:39 +00:00 by claude-bot
·
2 comments
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Quest-Board#399
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: CRITICAL
Found in the August 2026 session lifecycle review (#319).
The bot can tell a GM their recording is safe and then permanently delete it on its own next restart. Three separate paths leave a real, non-empty recording without the marker file the bot's startup cleanup relies on to know a directory is "owned" by the backend — and once 60 minutes pass, the sweep treats every unmarked directory as an abandoned scratch file and wipes it with
shutil.rmtree, with no distinction between "genuinely never recorded" and "recorded but not yet handed off."Evidence
bot/questboard_bot/main.py:361-367— the startup sweep iterates every session subdirectory in the audio temp dir; only a directory containingHANDOFF_MARKERis skipped (:364-365, "regardless of age"); anything else older thanmin_age_minutes(default 60,_DEFAULT_MIN_ORPHAN_AGE_MINUTESat:309) isshutil.rmtree'd at:367.bot/questboard_bot/cogs/recording.py:738-753— the marker is written (:753) only after a successfulPOST /api/bot/sessions/{id}/audio. If that POST raises (backend unreachable, network blip), theexceptblock at:745-753logs the error and tells the channel: "⚠️ The recording was saved but could not be submitted to Quest Board… an admin can retry processing from the console" — but no marker is written and nothing retries automatically. The next bot restart ≥60 minutes later deletes the whole directory the message just promised was safe.bot/questboard_bot/main.py:355-358andbot/questboard_bot/cogs/recording.py:127-134— a bot crash during recording leaves per-speaker raw WAVs ({session_id}_{user_id}_raw.wav) flushed to disk (crash-safe by design — they're opened inwbmode and written incrementally). The sweep's own docstring (main.py:320-322) asserts these directories have "nothing to preserve" and deletes them unconditionally on the next restart via the*_raw.wavglob (:355) — destroying hours of already-captured speech that nothing ever attempted to finalise or resume.recording.py:745-753) leaves a backend-owned directory (the DB row already points at it, Celery may already be reading it) unmarked, so it too is swept on a later restart if the bot goes down again in that narrow window.Failure scenario
A GM finishes a 4-hour session at the same moment a scheduled backend maintenance window starts. The bot's upload POST fails, the GM sees "saved but could not be submitted, an admin can retry," and moves on trusting that message. The bot gets redeployed for an unrelated fix two hours later. On startup, the sweep finds the directory unmarked and older than 60 minutes, and deletes it. The recording — the entire session — is gone, and no one finds out until someone tries to retry it from the console.
Proposed fix
Persist a manifest per recording at creation time (session dir path +
uploaded: bool) instead of relying purely on a post-hoc marker file, and have the bot retry the backend handoff on its own startup before ever considering sweeping unmarked directories — turn "sweep on age" into "sweep only after a bounded number of failed retry attempts, and only for directories the manifest says were never even attempted." For the mid-recording crash case, finalise (don't delete) raw WAVs found on restart — convert whatever was captured and attempt the normal handoff rather than discarding it. As a structural improvement, write theHANDOFF_MARKER(or the manifest'suploaded=true) before the POST, and let the backend's ack own transitioning ownership, so a crash right after the POST is indistinguishable from before it.Acceptance criteria
Criterion 3 — the last one open — is implemented in PR #470.
What it does. On its next start the bot converts the raw per-speaker tracks a mid-session crash left behind and hands them to the backend, so the session is transcribed and appears like any other. The earlier pass on this issue stopped the sweep deleting that audio and logged
needs finalising by hand; on a self-hosted bot at 2am there are no hands, so an interrupted recording sat on the volume indefinitely.Why this is safe to automate. Placement in the raw tracks is absolute — each is silence-padded from the session's t0 before every append — so the tracks are already mutually aligned and the session length is exactly
max(file size) / BYTES_PER_SECOND. Nothing is reconstructed or estimated; the timings and attribution are the recorded ones. That property is a hard dependency and is now documented inCLAUDE.md, because per-speaker private clocks are the whole of #320 and breaking it again would silently make recovery produce a scrambled transcript rather than fail.Two decisions worth recording:
Backend lookup for names, resolved at
on_readyrather thansetup_hook. The raw filenames carry only the session and user ids. Guild nickname is preferred over the global Discord name — a player who renamed themselves to their character keeps that identity (#344), and the sessions that already went wrong shouldn't also read worst. Nicknames need a warm member cache, which does not exist insetup_hook. It also runs as a background task so re-encoding hours of audio doesn't sit in front of the bot answering commands, and is guarded to fire once per process sinceon_readyrepeats on every gateway reconnect.GET /api/bot/sessions/{id}/summarynow also returnsnotification_channel_id, not justguild_id. This wasn't in the original plan and turned out to be load-bearing:process_audiopasses the channel straight through tosession_summarised, and the bot drops an event with an empty channel_id. Without it a recovered session would transcribe, summarise, and then say nothing in Discord — which from the GM's chair is indistinguishable from the recovery never having run. Both fields are additive and optional, so no contract bump.One hazard found while building it, worth noting because it is not obvious:
_retry_pending_handoffsruns first, insetup_hook, and can legitimately have just succeeded on a directory whose raw tracks are still present (a crash landing between the conversion and the ack). Re-submitting would queueprocess_audioa second time — and #397's 409 guard would not catch it, because the first run has not written a transcript yet. Recovery now skips any directory already carrying the handoff marker.Every other path errs towards leaving the audio alone. A raw track is unlinked only after the handoff is acked. A failed session lookup, or a campaign with no Discord server, converts nothing at all rather than sending a request guaranteed to be rejected. #397's refusal keeps the take and stops retrying it. Sub-threshold tracks are reported as present rather than as speakers, so #425 stays fixed.
15 new bot tests and 2 backend tests, each run against unfixed code first and confirmed to fail — including raws-deleted-before-ack, global-name-before-nickname, blank-guild-proceeds, duration-reported-as-zero, and the double-submit guard above.
Leaving this open until #470 merges. #416 can close at the same time — its remaining dependency was this.
Merged in #470 (
26d4328). All three criteria are now met, so closing.For the record, the three landed in two passes:
Criteria 1 and 2 — the startup sweep no longer deletes a directory holding audio, marked or not, and a converted-but-unsent recording is re-posted on the next start (
_retry_pending_handoffs). The test that asserted the opposite — it wrote a non-empty123.wavand required the sweep to delete it — was the defect encoded as the specification, and was rewritten to cover the empty case the sweep is actually for.Criterion 3 (#470) — a crash during capture leaves only raw per-speaker tracks, with no directory, manifest or marker to find, because all three are written at stop. The bot now converts and hands those off on its next start, resolving speaker names from the guild so nicknames survive (#344).
The property that makes the third one safe to automate at all: placement in the raw tracks is absolute — each is silence-padded from the session's t0 before every append — so the tracks are already mutually aligned and the length is exactly
max(file size) / BYTES_PER_SECOND. Nothing is estimated. This is now written intoCLAUDE.md, because it is a silent dependency: per-speaker private clocks are the whole of #320, and reintroducing them would make recovery emit a scrambled transcript rather than fail visibly.Nothing on any path deletes audio. A raw track is unlinked only after the handoff is acked; a failed lookup or a campaign with no Discord server converts nothing at all rather than sending a request certain to be rejected; #397's refusal keeps the take and stops retrying it.