J2 walks the session page; the seed records a session through the real pipeline for J5; emails no longer vanish after a worker's first (#730, #814) #815

Merged
claude-bot merged 4 commits from build/lane4-journeys into main 2026-09-24 19:07:42 +00:00
Collaborator

Closes #814. Lane 4's journeys (#730), plus a product fix the new journey found.

🐞 The bug J2 found (#814)

  • The bug. The email backend read its SMTP settings through the pooled engine on a temporary event loop. After the first email in each Celery worker process, every later email was silently skipped: confirmations, reminders, calendar invites and notifications. A group without Discord heard nothing, depending on which worker took the task.
  • The fix. It reads through task_session() now.
  • The regression test fails on the old code.
  • #129's guard test, which only scanned app/tasks/, now covers all of app/. The pooled engine is allowed only in database.py, main.py and cli.py, each of which lives on one event loop. A sweep found no other offender.

J2: the session half, now real (it was a test.skip)

  1. A vote. Dana proposes one, and the bot event is asserted. Priya's bell and Needs you land on it.
    • She votes: on the grid on desktop, the list on a phone, and Space/Tab on the keyboard project. Her answers survive a reload.
    • Dana confirms. The bot gets session_confirmed, and there is no email, because this is the Discord campaign.
    • Priya is told, answers Yes ("Saved — you are in."), and her Needs-you row goes.
    • axe runs on the vote, the confirmed page and the roster, each after its last request.
  2. Pencilled in (D50). Priya answers Maybe. Dana isn't asked, and confirms. The answer carries over.
  3. No Discord. A campaign with no guild: the confirmation arrives by mail(), and the bot gets nothing.

Step 6 (asking a question) is a TODO for lane 7 (#733).

J5's fixture: Session 4, "The Customs House at Low Tide", recorded through the real pipeline

  • The recording is generated at run time and never committed. The seed writes WAVs with stdlib wave into the named audio_temp volume: a quiet tone while each speaker talks, because the pipeline drops silent tracks. It then hands them to POST /api/bot/sessions/{id}/audio exactly as the bot does.
  • Priya is linked through the bot's link-token flow, so she's a matched speaker. Dana is deliberately unmatched.
  • The fake AI is scripted:
    • the transcript per track, recognised by filename;
    • six beats, five of which pass verification (one table_admin), and one that fails (IS-240);
    • highlights;
    • a recap built from the verified events. A strike or reword plus Rewrite therefore gives a different version.
  • The seed checks everything the pipeline made:
    • the transcript, the summary, and the run's beats and problems;
    • attendance proposals;
    • the post-session checklist;
    • the storyline's chapters;
    • the audio status against the retention mode, trashed under "bin once transcribed".
  • J6 counts played sessions from the fixture, not a constant.
  • Left for J5's spec (PR 7): Session 4 is shared by the three projects, so J5 will seed one per project or restore after itself. The README says so.

Checks

  • Journeys: 36 passed, 0 skipped, on desktop, phone and keyboard. That's two clean runs in a row after the fix; the first run's only red was the email bug.
  • Backend: the email-config, calendar-invite, recap-email and stack tests pass, and so do the guard tests. Ruff 0.4.4 is clean, and ESLint is clean over e2e/.

🤖 Generated with Claude Code

Closes #814. Lane 4's journeys (#730), plus a product fix the new journey found. ## 🐞 The bug J2 found (#814) - **The bug.** The email backend read its SMTP settings through the pooled engine on a temporary event loop. After the first email in each Celery worker process, **every later email was silently skipped**: confirmations, reminders, calendar invites and notifications. A group without Discord heard nothing, depending on which worker took the task. - **The fix.** It reads through `task_session()` now. - **The regression test** fails on the old code. - **#129's guard test**, which only scanned `app/tasks/`, now covers **all of `app/`**. The pooled engine is allowed only in `database.py`, `main.py` and `cli.py`, each of which lives on one event loop. A sweep found no other offender. ## J2: the session half, now real (it was a `test.skip`) 1. **A vote.** Dana proposes one, and the bot event is asserted. Priya's bell and Needs you land on it. - She votes: on the grid on desktop, the list on a phone, and Space/Tab on the keyboard project. Her answers survive a reload. - Dana confirms. The bot gets `session_confirmed`, and there is no email, because this is the Discord campaign. - Priya is told, answers **Yes** ("Saved — you are in."), and her Needs-you row goes. - `axe` runs on the vote, the confirmed page and the roster, each after its last request. 2. **Pencilled in (D50).** Priya answers Maybe. Dana isn't asked, and confirms. The answer carries over. 3. **No Discord.** A campaign with no guild: the confirmation arrives by `mail()`, and the bot gets nothing. Step 6 (asking a question) is a TODO for lane 7 (#733). ## J5's fixture: Session 4, "The Customs House at Low Tide", recorded through the real pipeline - **The recording is generated at run time and never committed.** The seed writes WAVs with stdlib `wave` into the named `audio_temp` volume: a quiet tone while each speaker talks, because the pipeline drops silent tracks. It then hands them to `POST /api/bot/sessions/{id}/audio` exactly as the bot does. - **Priya is linked** through the bot's link-token flow, so she's a matched speaker. Dana is deliberately unmatched. - **The fake AI is scripted:** - the transcript per track, recognised by filename; - six beats, five of which pass verification (one `table_admin`), and one that fails (IS-240); - highlights; - a recap built from the verified events. A strike or reword plus Rewrite therefore gives a different version. - **The seed checks everything the pipeline made:** - the transcript, the summary, and the run's beats and problems; - attendance proposals; - the post-session checklist; - the storyline's chapters; - the audio status against the retention mode, `trashed` under "bin once transcribed". - J6 counts played sessions from the fixture, not a constant. - **Left for J5's spec (PR 7):** Session 4 is shared by the three projects, so J5 will seed one per project or restore after itself. The README says so. ## Checks - **Journeys:** 36 passed, 0 skipped, on desktop, phone and keyboard. That's two clean runs in a row after the fix; the first run's only red was the email bug. - **Backend:** the email-config, calendar-invite, recap-email and stack tests pass, and so do the guard tests. Ruff 0.4.4 is clean, and ESLint is clean over `e2e/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
EmailNotificationBackend._load_config ran its read on a throwaway event
loop through the pooled AsyncSessionLocal. The pool outlives the loop, so
the first read in a worker process left a connection bound to a loop it
then closed, and every later read in that process failed "attached to a
different loop" — logged as a warning and returned as "no SMTP", so the
send was silently skipped. A campaign without Discord got its "Session
Confirmed" email only when the task landed on a worker process that had
never loaded the config; reminders, calendar invites and notification
emails share the read.

J2's no-Discord journey found it: red on desktop, green on phone and
keyboard in the same run, depending on which ForkPoolWorker took the
task. The task code around it already reads through task_session()
(reminder_tasks' note on #129 says why); this does the same.

test_email_backend_config pins it: two calls in one process, through
task_session() both times, never the pooled engine. It fails on the old
code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
J5 reviews a transcribed session, and there is nothing to review unless
the backend transcribed, extracted, validated and composed something. So
the seed now plays the bot at /record stop, after the other three
sessions, which makes this one Session 4, "The Customs House at Low
Tide", played ten days ago (between Sessions 1 and 2, so Last time and
Previously on stay Session 2's):

- Priya's Discord account is linked through the bot's own link-token
  flow (POST /api/bot/linking-tokens, then /auth/link as her, then the
  bot's link-status poll). Dana's is not, so one track is a matched
  member and the other an unmatched speaker (SE-142).
- Two 16 kHz mono WAVs and a speakers.json are written at run time with
  the stdlib wave module into the named audio_temp volume, now mounted
  into the seed: a tone while each line is spoken, silence elsewhere.
  No audio is committed; down -v removes them. The e2e-fakes image owns
  /tmp/questboard-audio as the bot's image does, because an empty named
  volume takes its owner from the mounting image and the product images
  lack the path.
- They are handed over as the bot does, POST /api/bot/sessions/{id}/audio
  with X-Bot-Key on the backend's own address, and the seed waits for
  audio_processing_status to leave processing.
- It asserts the result: trashed, worked out from the retention settings
  ("bin once transcribed" is the default); every scripted line in the
  transcript; the recap's paragraphs; the run's beats (four verified, one
  of them table_admin, one failed); Priya proposed as having spoken and
  Dana unmatched; every post-session task settled, none failed; and the
  story's third chapter written.

The fake AI scripts it all from identities.json's new `recording` block:
a transcript per track, recognised by the Discord id in the uploaded
file's name; beats, recognised by their schema, each citing the stamp of
the line holding its phrase in the prompt it was sent, plus one citing a
second no line starts on (IS-240); verbatim highlights; and a recap
written from the events the compose prompt lists, so a GM's strike or
rewording changes the next version.

J6 counts the story's chapters and the timeline's evenings from the
played sessions rather than as two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
J2's lane 4 half, which was a test.skip with a TODO. Three serial acts
sharing two signed-in people:

1. Dana puts three times up (API; J1 walks the form). The bot is sent
   the vote — on /notify, its HTTP fast path. Priya's Needs you says
   "Vote on a time", and the bell's row lands her on the session. She
   votes: the grid on a desktop, the list on a phone (D29), Space and
   Tab alone on the keyboard; a reload reads the answers back. Dana
   confirms the time ahead; the bot is told over the durable stream, and
   no email goes because the bot took it. Priya is told (Needs you and
   the bell), answers Yes, the roster says In, and her Needs you row for
   it goes while Session 3's stays.
2. Pencilled in (D50): Priya says maybe, Dana (who is not asked) confirms
   it and is told the one answer carried over, and Priya's page reads
   Maybe, pressed, with nothing left in Needs you.
3. A campaign of the spec's own with no guild: the same vote, the
   confirmation arrives by mail(), and nothing reaches a bot.

axe() runs on the vote page (Priya's and Dana's), the pencilled page,
the confirmed page and again on the roster once her answer is read back,
each after the page's last request: nothing Loading, the notes, the
availability request, Previously on and How the time was picked.

openAs() in fixtures.js gives each person a context with this project's
device, signed in once — a sign-out and back in per turn would cost a
sign-in per turn against the limiter — and guards it in the keyboard
project like the fixture's page. Test 1 now finds Session 3's row rather
than counting all of Needs you, so a failure in the half below cannot
surface in the next project as an off-by-one.

Step 7, asking a question, is lane 7's and left as a TODO naming #733.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(backend): the pooled engine is barred everywhere a task can reach (#814)
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 17s
CI / Bot/backend version sync (pull_request) Successful in 55s
CI / Backend lint (ruff) (pull_request) Successful in 58s
CI / Docker image build (pull_request) Successful in 1m0s
CI / Feedback site tests (pull_request) Successful in 1m17s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m29s
CI / Bot tests and audit (pull_request) Successful in 2m25s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m29s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 9m58s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m4s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m35s
47c5768907
#129's guard scanned only app/tasks, and the email backend in
app/notifications, which task code calls, used the pooled engine. Now
all of app is scanned. Only database.py, main.py and cli.py are allowed,
because each lives on one event loop. It matches uses (a call or an
import), not prose that names the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.