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
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/menu,/help/lessonplan,/video,/quiz,/readingtest,/assessmentpaused, neverabandoned.MenuService.Plus an evening nudge (20:00–21:59 Asia/Karachi, once per pause) and a
RESUMEkeyword.Two traps a naive fix would walk into
1.
/menuwould re-trap the teacher. It setsAWAITING_MENU_CHOICEand waits on a bare1–4. 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):
1–4→ costs ~1 answer in 1,5002. A paused session would silently self-destruct.
stale-session.worker.jsauto-completesconducting_conversationat 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
/menuusable at all. So by the timeMenuServiceruns, the ask-first opportunity has passed.guardMenuSelection()puts it back at both menu entry points.menu_coachingis 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:
RailwayRedisServiceexposesdelete(), notdel()— would have thrown on every confirmation clear.get()already JSON-parses — an extraJSON.parsewould have thrown on a valid payload.trimmedMessageisconstand 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 mathssurvives.NUM_REFLECTIVE_QUESTIONSis 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.handleMenuButtonResponseis(user, from, buttonId, language)— the first draft had the arguments in the wrong order.awaiting_menu_selectionstate 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-stringsflagged the new English literals. All six strings now live incoaching-messages.jswith 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.jspinnedstatus: '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
openaimodule indashboard/tests/embedder.test.js.)V1.0.12__coaching_pause.sqlvalidated against real PostgreSQL 16: clean apply, idempotent re-apply, columns + partial index present, DOWN block confirmed to revert paused → abandoned and leaveidx_coaching_sessions_staleintact.cross-agent-safetychecklist: 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;pausedis a new value (no CHECK constraint onstatus). Also added to the00_complete-schema.sqlreconcile section perinfrastructure/CLAUDE.md, or fresh installs would lack the columns.reminder_sent_atwas deliberately not reused — the 2h stale reminder owns it, and sharing would make either ping suppress the other.Notes for review
V1.0.12. The companion ICT-spine PR addsV1.0.11; merging that first keeps the sequence gapless.