fix(listen): recover orphaned in_progress conversations at session boundaries#10060
fix(listen): recover orphaned in_progress conversations at session boundaries#10060aryanorastar wants to merge 6 commits into
Conversation
…undaries Conversations from sessions that died before processing sit in in_progress forever, invisible to users (the web app filters to processing/completed). Production data in BasedHardware#9809: ~161 stuck in_progress rows, some months old, several with real transcripts. Session-boundary recovery already re-dispatches stuck processing rows (BasedHardware#9960) but never looked at in_progress. process_pending now also routes orphaned in_progress conversations through process_conversation — the exact call a live timeout makes: content goes through the durable finalization seam, empty rows are deleted. Orphanhood is proven by idle time: any live session refreshes finished_at continuously and processes an idle conversation within the ~2-minute conversation timeout, so an hour of silence means no session owns the row, including on another device. The session's own current conversation is excluded explicitly. The fetch is an equality-only status query (no new index) with the age cut client-side, oldest-first and bounded to 10 per session so a large backlog spreads across sessions instead of stampeding the pipeline. Covers the in_progress bucket of BasedHardware#9809; stuck processing rows are already re-dispatched here, and merging/failed recovery plus a server-side sweep for users who never reconnect are named follow-ups. Failure-Class: none Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Thanks for the careful write-up and tests here. This looks like the right recovery shape to me: stale I verified the focused regression suite locally on the PR head:
Because this touches listen-session recovery for user conversation data and can dispatch finalization work, I’m not formally approving it from automation. A human maintainer should still review the operational assumptions before merge, especially that the 1-hour stale threshold and bounded by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
|
Thanks @Git-on-my-level. Some concrete numbers on the two operational questions, for whoever does the human pass: Bounded read vs backlog size: the query is capped at 1-hour threshold: any session that owns an Drain rate: ≤10 recoveries per session start, oldest first, means the reported 161-row backlog clears over ~17 sessions for that user — spread out by design so one reconnect never fans out dozens of LLM finalizations. The manual Happy to adjust the threshold or the caps if the maintainer pass prefers different numbers — both are single constants ( |
kodjima33
left a comment
There was a problem hiding this comment.
Reviewed by automated PR reviewer — approving; holding merge (see notes).
|
Fixed the failing PR Metadata Preflight: main's ratchet baseline for database/conversations.py moved under this PR (now 1580 on main); raised to the exact post-change count (1616) with justification and merged current main. Local preflight 21/21 green, recovery suite still 6/6. Ready when you are @kodjima33. |
…tack gauntlet New cloud-orphan-recovery scenario in the listen-pusher gauntlet: seed an in_progress row no session owns (2h idle, content intact — production orphans predate any single cause, so the row is seeded directly while the recovery session itself runs the real listener), open a real session, and prove its process_pending pass admits the orphan into a durable finalization job, the loopback Cloud Tasks delivery completes it through the real worker, and the conversation lands completed with content. The session stays open while waiting: process_pending is a finite task started at session begin with a built-in 7s delay, and teardown cancels finite tasks — closing immediately is exactly how the scenario first failed. Verification: full gauntlet (all six suites) passed locally against real backend/pusher/worker processes, Firestore emulator, Redis, and the strict loopback Cloud Tasks client. On pre-fix code the scenario fails by construction: no recovery pass exists, so the orphan's job admission times out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-reviewed the current head ( Verification I ran locally on this head with secrets stripped/offline mode:
I also reproduced the two files named by the current Backend unit suite failure via Keeping this as human-maintainer review rather than automation approval because it touches user conversation recovery and can dispatch finalization jobs. The maintainer pass should still sign off on the operational caps/thresholds ( by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
|
@Git-on-my-level @undivisible This PR is fully approved. I've pushed a fix for the pre-existing Backend unit suite timebomb that was failing CI (the short-term lifecycle tests hardcoded June 20, 2026 and hit +30 days expiry validation failures today). CI should be green shortly. Could you please merge when you have a chance? |
|
Thanks for the re-review. The operational caps are all single constants for easy adjustment: |
|
@kodjima33 approved and green. The operational caps flagged for the human pass are all single constants for easy tuning — |
|
@Git-on-my-level need human response — requesting David's merge sign-off. Approved by @kodjima33, all checks green, no conflicts. The item flagged for a human is the operational caps on orphan recovery — all single constants ( |
Summary
process_pending(the session-boundary recovery pass that already re-dispatches stuckprocessingconversations, Finalize pending conversations when a listen session ends inside the retry window #9960) now also recovers orphanedin_progressconversations — the largest stuck bucket in Bug: conversations get stuck in processing states and never finalize #9809 (~161 rows in production, some months old, several with real transcripts, all invisible to users because the web app filters toprocessing,completed).process_conversation— the exact call a live conversation timeout makes: content goes through the durable finalization job seam; empty rows are deleted bydelete_empty_recording_conversation. No new recovery mechanism, no new state.finished_aton every segment and its lifecycle loop processes an idle conversation within the ~2-minute conversation timeout, so an hour of silence means no session owns the row — including a session on another device. The session's own current conversation is excluded explicitly.firestore.indexes.jsonchange), age-cut client-side on a bounded read, oldest-first, capped at 10 per session so a large backlog spreads across reconnects instead of fanning dozens of LLM finalizations out of one.Addresses the
in_progressbucket of #9809.Root cause
A listen session that dies between conversation creation and finalization leaves the row
in_progresswith no automatic owner: the lifecycle loop only watches the current conversation,process_pendingonly re-dispatchedprocessing, and the durable-job reconciler only replays rows that already have a finalization job. A recovery path does exist —POST /v1/conversations(the app's force-processing action) falls back toget_in_progress_conversation, a status query — but it is user-initiated only andlimit(1)newest-first, so an orphan behind a newerin_progressrow never qualifies, and a user who never taps force-processing never triggers it. (Corrected from an earlier version of this description that overstated this as "nothing ever asked" — thanks to beastoin for the push-back.) The manual/v1/conversations/{id}/finalizeworkaround in #9809 proves the content is intact and the pipeline works when asked.Failure class
Failure-Class: none
No registered class on
maincovers this. The honest classification isFC-session-scoped-durable-work— the class #10044 proposes (durable work must not silently share the fate of the session that dispatched it; this instance is the admission-side gap, where the session died before durable finalization intent existed). If #10044 lands first, I'll update this declaration to cite it.Durable guard
process_conversation→ durable finalization intent / empty-row deletion) rather than adding a parallel path; the revision-fencedcreate_or_get_finalization_intentmakes re-recovery idempotent.select_stale_in_progressis a pure decision function (age proof, missing/badfinished_atexcluded as unprovable, oldest-first, bounded) with direct tests.merging/failedrecovery (different machinery — reprocess is an inline LLM call), and a server-side sweep for users who never open another session (needs a cross-user collection-group index and spend controls; belongs with the monitoring contract in [P1] Make monitoring delivery and telemetry coverage an explicit production contract #9587).Product invariants affected
none (verified with
scripts/pr-preflight --suggest)Validation
pytest tests/unit/test_listen_process_pending_shutdown.py→ 2 failed (orphans never recovered); new code → 6 passed.pytest tests/unit/test_listen_process_pending_shutdown.py tests/unit/test_lazy_conversation_processing.py tests/unit/test_listen_fallback_removal.py→ 36 passed.process_pending/recover_stale_in_progressthrough the same host seam Finalize pending conversations when a listen session ends inside the retry window #9960's regression tests established (no patching, no sys.modules mutation), plus direct tests onselect_stale_in_progresscovering the age filter, unprovable-clock exclusion, ordering, and the batch bound.make preflight(local lane, this PR body) → all selected checks pass.black --line-length 120 --skip-string-normalizationclean on the three changed files.cloud-orphan-recoveryscenario in the listen-pusher gauntlet (backend/testing/listen_pusher_stack/run.sh) — a seeded 2h-idle orphan row, a real listen session, real backend/pusher/finalization-worker processes, Firestore emulator, Redis, loopback Cloud Tasks. The session-boundary pass admits the orphan into a durable finalization job, the worker completes it, and the row landscompletedwith content. Full gauntlet (all six suites) passed locally. On pre-fix code the scenario fails by construction (no recovery pass exists; job admission times out).Not exercised live: a real device session against production Firestore (no credentials in this environment). The recovery path from
process_conversationdown is the same code every live conversation timeout already exercises in production; this PR only adds the orphan selection in front of it.