Skip to content

fix(coaching): confirm before ending a reflection; pause instead of abandon (bd-2508 follow-up) - #120

Open
hammad-sarfraz-1 wants to merge 3 commits into
Orenda-Project:developfrom
hammad-sarfraz-1:fix/bd2508-confirm-before-abandoning-coaching
Open

fix(coaching): confirm before ending a reflection; pause instead of abandon (bd-2508 follow-up)#120
hammad-sarfraz-1 wants to merge 3 commits into
Orenda-Project:developfrom
hammad-sarfraz-1:fix/bd2508-confirm-before-abandoning-coaching

Conversation

@hammad-sarfraz-1

Copy link
Copy Markdown

A slash command mid-reflection silently destroyed the rest of the coaching conversation. Now the teacher is asked, and the session pauses instead of dying.

The bug

bd-2508 closed the 269-hour trap by ending the session on any / message. That made escaping the trap and destroying the reflection the same action — no warning, no way back. Answers already given survived; every remaining question was dropped and the session was terminal.

Three outcomes now

Input Behaviour
/menu, /help Exempt. Session left running — the documented escape hatch gets no confirmation gate.
/lessonplan, /video, /quiz, /readingtest, /assessment Ask first, naming the service she asked for. On YES → paused, never abandoned.
Menu picks (buttons + typed digits) Same gate, re-applied in MenuService.
Free text Unchanged, straight to the coach.

Plus an evening nudge (20:00–21:59 Asia/Karachi, once per pause) and a RESUME keyword.

Two traps a naive fix would walk into

1. /menu would re-trap the teacher. It sets AWAITING_MENU_CHOICE and waits on a bare 14. But the coaching interceptor runs ~1,000 lines before the menu handler, so it eats that digit as a reflective answer — she escapes and is instantly recaptured. Exactly what the original bd-2508 comment predicted.

Fixed with a narrow digit exemption, measured before designing it (RUMI_DB, 2026-08-04):

  • 5 of 7,644 reflective answers are a bare 14 → costs ~1 answer in 1,500
  • 365 of 7,644 are ≤ 2 chars → a looser rule would eat real answers

2. A paused session would silently self-destruct. stale-session.worker.js auto-completes conducting_conversation at 12h idle. A paused session is deliberately idle, so without an explicit exclusion the pause becomes a partial report — the same data loss this fix prevents.

The menu path needed its own gate

A slash command is visible to the coaching interceptor, which asks before the command runs. A menu pick arrives as a button/digit the interceptor deliberately defers — and that deferral is what makes /menu usable at all. So by the time MenuService runs, the ask-first opportunity has passed. guardMenuSelection() puts it back at both menu entry points.

menu_coaching is gated too: it asks for a new lesson recording, so the reflection she is already in must be paused rather than orphaned. menu_other (general chat) starts no service and is never gated.

Caught during implementation

Reading the real code caught five things a plan alone would have shipped broken:

  • RailwayRedisService exposes delete(), not del() — would have thrown on every confirmation clear.
  • Its get() already JSON-parses — an extra JSON.parse would have thrown on a valid payload.
  • trimmedMessage is const and lowercased, so replaying a confirmed command by mutating it is impossible and would corrupt arguments. Recursion with the stashed original text instead, so /lessonplan grade 4 maths survives.
  • NUM_REFLECTIVE_QUESTIONS is 1, not 4 (coaching-debrief.config.js:31). The prompt imports the constant and switches phrasing at N=1; a test greps the source to forbid a hardcoded total.
  • handleMenuButtonResponse is (user, from, buttonId, language) — the first draft had the arguments in the wrong order.
  • The button path deletes the awaiting_menu_selection state before dispatching, so a guard placed after it made a confirmed YES land on "that menu selection has expired". The guard now runs first, with a source-order test pinning it.

i18n

tests/setup/no-hardcoded-coaching-strings flagged the new English literals. All six strings now live in coaching-messages.js with Urdu translations, using {{placeholder}} substitution rather than ${} so they stay translatable 1:1.

An existing test asserted the bug

tests/handlers/coaching-slash-exit.test.js pinned status: 'abandoned' — the exact behaviour being removed. It is inverted rather than deleted, now asserting the pause, the confirmation, the exemption and the digit deferral, with the rationale recorded inline.

Verification

  • New suite: 31 tests, proven RED before any implementation, green after. 39 including the updated slash-exit suite.
  • Full suite: 148 failing suites before and after — zero regressions. Verified by stashing, not assumed. (Pre-existing failures include a missing openai module in dashboard/tests/embedder.test.js.)
  • V1.0.12__coaching_pause.sql validated against real PostgreSQL 16: clean apply, idempotent re-apply, columns + partial index present, DOWN block confirmed to revert paused → abandoned and leave idx_coaching_sessions_stale intact.
  • Ran the repo's cross-agent-safety checklist: imports verified, no chained multi-WHERE update, critical and best-effort writes kept separate, columns confirmed against the schema.

Schema

Three nullable columns (paused_at, pause_reason, evening_reminder_sent_at) plus a partial index. Additive; paused is a new value (no CHECK constraint on status). Also added to the 00_complete-schema.sql reconcile section per infrastructure/CLAUDE.md, or fresh installs would lack the columns.

reminder_sent_at was deliberately not reused — the 2h stale reminder owns it, and sharing would make either ping suppress the other.

Notes for review

  • Nothing applied to Supabase; the migration has not been run.
  • The 15-minute cron is configured in the Railway dashboard, not in any repo file — someone with access should confirm the live schedule before trusting the evening window.
  • Contains V1.0.12. The companion ICT-spine PR adds V1.0.11; merging that first keeps the sequence gapless.

A slash command during conducting_conversation currently sets the session to
abandoned with no warning and no way back. The bd-2508 comment explains why
(the state was a trap; one teacher was held 269 hours), but escaping the trap
and destroying the reflection are now the same action.

Plan adds a third option: confirm, pause, resume.

  /menu and /help  -> pass through, session LEFT RUNNING (the escape hatch must
                      work first try, so no confirmation gate)
  service commands -> prompt naming the service she asked for; on YES the
                      session becomes 'paused', never 'abandoned'
  evening 20-22 PKT -> one nudge per pause; RESUME picks the questions back up

Measured before designing (RUMI_DB, 2026-08-04): only 5 of 7,644 reflective
answers are a bare digit 1-4, which is what makes the narrow menu-digit
exemption safe. 365 of 7,644 are <= 2 chars, so a looser rule would eat real
answers.

Two traps the plan closes that a naive fix would miss:
  * /menu sets AWAITING_MENU_CHOICE and waits on a bare digit, but the coaching
    block runs ~1000 lines earlier and would swallow it -- the teacher escapes
    and is instantly re-caught, exactly as the original comment predicted.
  * stale-session.worker.js auto-completes conducting_conversation at 12h idle.
    A paused session is deliberately idle, so without an explicit exclusion the
    pause silently becomes a partial report -- the same data loss this change
    exists to prevent.

Corrected while planning: the prompt was drafted around '2 of 4 questions', but
NUM_REFLECTIVE_QUESTIONS is 1 (coaching-debrief.config.js:31, one question per
observation, was 3). The service now imports the constant and switches phrasing
at N=1; a test greps the source to forbid a hardcoded total.

Also noted: the 15-min cron is configured in the Railway dashboard, not in any
repo file -- T2.1.1 must confirm the live schedule before relying on the window.

Plan only. No code changed.
…bandon

bd-2508 closed the 269-hour trap by ending the coaching session on any slash
command. That made escaping the trap and destroying the reflection the same
action, with no warning: answers survived but every remaining question was
dropped and the session was terminal.

Three outcomes now, not one:

  /menu, /help     -> exempt, session LEFT RUNNING. These are the documented
                      escape hatch, so they get no confirmation gate.
  service command  -> ask first, naming the service she asked for; on YES the
                      session becomes 'paused', never 'abandoned'
  free text        -> unchanged, straight to the coach

Two traps a naive fix would miss, both closed here:

1. /menu sets AWAITING_MENU_CHOICE and waits on a bare 1-4, but the coaching
   interceptor runs ~1000 lines before the menu handler and would swallow that
   digit -- the teacher escapes and is instantly recaptured, exactly as the
   original bd-2508 comment predicted. A narrow digit exemption defers it.
   Measured first (RUMI_DB 2026-08-04): only 5 of 7,644 reflective answers are
   a bare 1-4, so this costs ~1 answer in 1,500. A looser rule would be unsafe:
   365 of 7,644 answers are <= 2 chars.

2. stale-session.worker.js auto-completes conducting_conversation at 12h idle.
   A paused session is deliberately idle, so without an explicit exclusion the
   pause silently becomes a partial report -- the same data loss this fix exists
   to prevent.

Evening nudge (20:00-21:59 Asia/Karachi, once per pause via a dedicated
evening_reminder_sent_at column) plus a RESUME keyword completes the loop.
reminder_sent_at was NOT reused: the 2h stale reminder owns it and sharing one
column would make either ping suppress the other.

Caught during implementation:
  * RailwayRedisService exposes delete(), not del() -- the first draft would
    have thrown on every confirmation clear.
  * Its get() ALREADY JSON-parses, so the draft's JSON.parse would have thrown
    on a valid payload.
  * trimmedMessage is const AND lowercased, so replaying a confirmed command by
    mutating it is impossible and would corrupt arguments; recursion with the
    stashed original text is used instead.
  * NUM_REFLECTIVE_QUESTIONS is 1, not 4 -- the prompt imports the constant and
    switches phrasing at N=1 rather than hardcoding a total.
  * tests/setup/no-hardcoded-coaching-strings flagged the new English literals.
    All six strings now live in coaching-messages.js with Urdu translations.

tests/handlers/coaching-slash-exit.test.js pinned status:'abandoned' -- that
assertion encoded the bug, so it is inverted with the rationale recorded inline.

Full suite: 148 failing suites before and after (all pre-existing, incl. the
missing 'openai' module in dashboard/tests/embedder.test.js) -- zero regressions.
New suite: 25 tests, proven RED before implementation.
The previous commit gated slash commands but left a hole: picking 'Lesson plan'
from the menu still orphaned a live reflection silently. This closes it.

Why it needed a separate gate. A slash command is visible to the coaching
interceptor, which asks before the command runs. A menu pick arrives as a button
or a bare digit that the interceptor deliberately DEFERS -- and that deferral is
precisely what makes /menu usable mid-reflection. So by the time MenuService
runs, the ask-first opportunity has already passed. guardMenuSelection() puts it
back at the menu's own two entry points (buttons and typed digits).

Gated: menu_lesson_plan, menu_video, menu_reading, menu_training, and
menu_coaching / '1'. Coaching is included on purpose -- it asks for a NEW lesson
recording, so the reflection she is already in must be paused and nudged rather
than left hanging.

Not gated: menu_other / '4'. It opens general AI chat, starts no service, and
nagging a teacher who just wants to ask a question would be noise. Same posture
as /help.

Two bugs caught while wiring it:
  * handleMenuButtonResponse is (user, from, buttonId, language) -- the first
    draft passed (buttonId, from, user) and would have failed at runtime.
  * The button path deletes the awaiting_menu_selection Redis state BEFORE the
    switch. Placing the guard after that delete meant a confirmed YES replayed
    into 'that menu selection has expired'. The guard now runs first, and a
    source-order test pins it so the two cannot be reordered again.

A menu pick has no re-runnable command text, so the confirmation stashes the
selector and the YES branch dispatches it directly instead of recursing with
message text.

New tests: 6 (31 total in the suite). Full suite: 148 failing suites before and
after -- zero regressions.
@hatafatif
hatafatif changed the base branch from main to develop August 10, 2026 07:00
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