Meeting lifecycle recovery — design spec
Status: Planned
Author: Christophe
Date: 2026-07-21
Repo: north-os (packages/meetings, apps/worker-meetings, apps/web/lib/meetings)
Related: WS-4 of 2026-07-21-meeting-production-readiness · review docs/reviews/2026-07-21-meeting-system-review.md (findings 4, 9, 15, 16 + the best-effort-emit caveat).
Problem
The transcription lifecycle has two unrecoverable stuck states and a fire-and-forget event. After the pending→transcribing CAS (process.ts:68-83), a worker crash (OOM decrypting a large blob, deploy SIGKILL) leaves the row transcribing forever: BullMQ redelivers, the handler throws CasConflictError, and the consumer treats it as a benign race and ack-drops (consumer.ts:36-49). A failed bus.emit after a durable insert strands the row pending forever. And source.meeting.transcribed is emitted best-effort with no outbox (process.ts:130-151) — fine for the summary consumer today, load-bearing the moment a second consumer (RAG, WS-12) depends on it. The summary path already has the missing mechanism (15-min stale-generating reclaim, runtime.ts:386-395) — the asymmetry is the gap.
Design
- Owner-lease claims. Add
claimed_by/claimed_atto the transcription claim. Redelivery acknowledges only a fresh claim owned by another live worker; atranscribingrow whose lease exceeds lock+grace is reclaimable under CAS back topending. (Revised 2026-07-22 — see Revision below: no new columns;updated_atis the lease clock.) - Stale-transcribing sweep. A scheduler loop (mirror of summary-backfill) releases leases past grace to
pendingand emits an operator signal.markFailed's silent catch (process.ts:224-227) gains a real log. - Pending-rescue sweep. A row durably committed but never dispatched (emit failed / web crash between insert and emit) is re-driven with a fresh
deliveryJobId. The web route returns 202 once the row commits, even if the immediate emit fails. - Retryable classification fix.
AudioFetchError(transient R2 5xx) joins the release-for-retry class instead of terminal-on-first-attempt (errors.ts:61vsprocess.ts:167-193). - Worker exit semantics. Non-zero exit on the fatal-exception path (so systemd
Restart=on-failurerestarts), plus anunhandledRejectionhook (worker-meetings/src/index.ts:196-204). - Durable transcribed-event (WS-12 prerequisite). Make
source.meeting.transcribedat-least-once: re-emit-from-readyon a sweep so a lost emit is recovered. Consumers must be idempotent onmeetingId. (Revised 2026-07-22 — see Revision below: seam documented, blanket sweep not built in WS-4.)
Revision — 2026-07-22 (as implemented, NOS-193)
The per-workstream plan in Linear NOS-193 was adversarially verified against main and refined two of the design bullets above. The original text is kept for the record; this section is what shipped.
- Lease clock =
updated_at, no new columns.meeting.updated_athas$onUpdate(now())and everymeetingwrite goes through Drizzle.update(); the only writes during thetranscribingspan are the claim itself and the terminal transition, so the timestamp set at thepending→transcribingclaim is a stable lease start — exactly the property the summary path's stale-generatingreclaim already relies on. Owner identity is already recorded per transition inmeeting_audit.actor, soclaimed_by/claimed_atadd a migration without adding recovery power. No migration;scheduler_rolegrants unchanged. A DB guard test pins the$onUpdatelease assumption. Redelivery semantics are unchanged (CAS conflicts still ack-drop); recovery is owned entirely by the sweep. Poisoned rows are bounded by an audit-count reclaim cap (Nreclaims, then quarantine tofailedwith a distinct reason — still an operator-visible terminal state). - Pending-rescue is dispatch-touch gated. Each stale
pendingrow is claimed for dispatch by a CASupdated_atbump before its re-emit, so re-drives are grace-bounded and deduplicated across worker replicas; re-emits pass a freshdeliveryJobId(the plainmeetingIdjob id would dedup against the retained original). - Transcribed-event durability is a documented seam, not a WS-4 sweep. The only live consumer (summary) is already re-driven by the summary-backfill sweep; a second blanket re-emit-from-
readyhas no delivery ledger and would only re-hitclaimSummary's ack-drop. WS-12's RAG consumer gets a natural ledger for free (readymeetings with no chunks) and must (a) be idempotent onmeetingIdand (b) own its re-drive sweep against that ledger. The at-least-once guarantee for the transcribed event therefore lands with WS-12, on WS-4's mechanics. - Fidelity caveat.
languageHintis event-only (not persisted onmeeting), so recovery re-emits carrylanguageHint: null; providers auto-detect.
Invariant & acceptance
No meeting is ever stuck with no operator signal, and every ready meeting's transcribed-event is eventually delivered at least once (the second clause lands with WS-12 — see Revision; WS-4 ships the mechanics and the seam). Acceptance: a crash-after-CAS integration test (kill between the CAS and the catch, assert the row returns to pending and re-transcribes), a lost-emit test (assert pending-rescue re-drives), and an at-least-once test for the transcribed event (the last with WS-12).