Tell an empty meeting apart from one that cannot reach the gateway - #363
Open
lilseyi wants to merge 2 commits into
Open
Tell an empty meeting apart from one that cannot reach the gateway#363lilseyi wants to merge 2 commits into
lilseyi wants to merge 2 commits into
Conversation
recoverInterruptedRecordings() folded every device-restart-while-recording session to `failed` with a reason claiming "what was recorded is kept below" — even when nothing was recorded at all. A recording killed within seconds now goes through the same end -> empty path controller.end() already uses, with an honest reason and no Retry that could never succeed. markSyncFailed() also parked every record that failed six reconnections behind one generic "failed ... several times ... try again, or copy your notes out" sentence, whatever the record held. A session with nothing captured now gets an honest, permanent sentence instead, and a genuinely transient failure carries the gateway's own last reason rather than a bare attempt count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
Sabotage-testing markSyncFailed's classification and recoverInterruptedRecordings' hasNothingCaptured branch showed both guards catch a reversion, but the MeetingNoteScreen rendering for a parked NOTHING_CAPTURED rejection had no direct coverage. Written against the store directly, since the ordinary paths no longer reach this branch after the fix in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
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.
The defect
Verified on hardware: a meeting killed mid-recording with real audio recovers on Retry in about seven seconds and files correctly. The same kill with nothing captured — no transcript, no typed notes — cannot file, and should not. Both are the intended behaviour. What the second one said was wrong:
A retry-forever sentence for a permanent, correct, benign outcome: there is nothing to try again and nothing to copy out, because the meeting is empty.
Root cause, once traced end to end
Landing()inMeetingNoteScreen.tsxalready checkssession.state === "empty"before it ever readsrecord.rejection, andcontroller.end()already keeps a session with nothing captured out of the sync queue by folding it straight toempty. Both of those were right. What was not taught the same rule wasrecoverInterruptedRecordings()— the one thingconfigure()runs on the phone's nearest thing to a launch, for a session leftrecording/pausedby a killed process. It folded every such session tofailedwithINTERRUPTED_RECORDING_REASON("...so the rest of this meeting was not captured. What was recorded is kept below."), whether or not there was a "rest" or anything "kept below" — asserting content a session with nothing in it never had, and leaving it in a state (failed) that reads as transient when the true fact is permanent.A
failedsession with nothing captured could then sit in the sync queue (asessionmetadata PUT is pending on any state change) and, on a genuinely bad connection, exhaustMAX_SYNC_ATTEMPTSand hitmarkSyncFailed's own generic "several times... try again... copy your notes out" sentence — which never asked whether the record had anything worth that framing.The fix (copy + classification, not behaviour)
recoverInterruptedRecordings(apps/mobile/features/meetings/controller.ts) now checkshasNothingCapturedexactly asend()does. Nothing captured →endthenempty(the only legal route toemptyperMEETING_TRANSITIONS), with a new, honestINTERRUPTED_EMPTY_REASON. Something captured → unchanged:failedwithINTERRUPTED_RECORDING_REASONand its Retry, exactly as before.markSyncFailed(apps/mobile/features/meetings/record.ts) is the backstop for anything that still reaches the retry-exhausted path with nothing captured: it now returns a distinctNOTHING_CAPTUREDcode and an honest, permanent sentence instead of the retry-forever one. For the genuinely transient case, the message now carries the refusal's own last reason (message) instead of a bare count of attempts.MeetingNoteScreen.tsxrendersNOTHING_CAPTUREDthe same way it rendersstate === "empty"— neutral tone, no "This meeting has not left the device" crit banner — for the case where that backstop is ever reached.docs/decisions/meetings.md("A permanent, correct refusal is not the same fact as a transient one...") argues the classification change, since instruction was to raise behaviour changes rather than make them silently.Neighbouring copy checked, nothing else changed
Per the instruction to check neighbouring states for the same fault:
MeetingNoteScreen's otherLanding/Summarybranches (failed,finalizing, folder-rejected, still-sending) each condition their sentence on a fact the session or record actually carries (failureReason,acked.finalized,folderRejected,pendingSteps) — none assert an unverified cause.convexGateway.ts'sMEETING_WRITE_SENTENCES/asGatewayErrormapping is a carefully-argued, code-by-code attribution (see its own header) — not a "always blames X" pattern.rejectionNotice()'sforbiddenbranch ("Connect it again from Settings") is a generic recovery suggestion across three different underlying causes (readOnly,notAMember,unreadableFolder); it doesn't misstate a cause but is worth a second look if this class of bug recurs — flagged, not changed, to keep this PR to the reported defect.RecordingBar/LiveMeetingScreen'sSyncChip("Needs you") andMeetingRow's badges are correctly generic/neutral and already treatemptyas its own non-error state.No change to
MEETING_TRANSITIONS,checkFinalizeTimeout/FINALIZE_TIMEOUT_MS, orhasNothingCaptured's own definition.recoverStaleFinalizeswas not touched: a session that reachedfinalizingat all had astart/resumebehind it, sopendingStepswould already have real content queued ahead of any finalize.Sabotage counts
Each guard was broken on purpose and restored, per commit
Tell an empty meeting apart from one that cannot reach the gateway:markSyncFailedto the original single-branch version → 2 FAIL (six failed reconnections on a meeting with nothing in it never say 'try again', and the "carrying the real reason" assertion in the sibling test).recoverInterruptedRecordingsto the original unconditionalfail→ 1 FAIL (a recording killed within seconds — nothing captured — is empty, not failed).NOTHING_CAPTUREDbranch inMeetingNoteScreen'sLanding→ 1 FAIL (a parked meeting with nothing in it never says try again, even as a backstop).All three restored cleanly afterward; full suites green again.
What needs a Mac to confirm
Everything here was exercised through the existing Jest/Node harnesses (
memoryStore,fakeGateway,fakeRecorder, a controller driven directly with an explicit clock) — the same style the rest ofmeetingsController.test.tsuses for "the app being killed mid-meeting". The one thing not reproducible in this sandbox is the actual hardware scenario the task names: killing the desktop/phone process seconds into a real recording and watching the relaunch, ideally on a flaky connection, to see the correctedNothing was captured/ "Record again" landing in place of the old sentence, and to confirm no other surface (tray, watch complication, push notification) still quotes the old wording — none were found in this repo, but a device-level smoke pass is the only way to be fully sure nothing outside version control (a cached bundle, a stale native module) still holds it.Test results
apps/mobile:pnpm test— 221 suites / 4101 tests, all green (run--runInBand; one pre-existing test,linkComplete.test.ts, flakes under paralleljestload only — noted in the task brief, reproduced, unrelated to this change, passes standalone). Typecheck clean.apps/desktop: suite green, typecheck clean (no files touched by this PR).packages/meetings:ALL PASS(no files touched).apps/mcp:ALL PASS, 3070 checks (no files touched).Diff is
apps/mobile+docs/decisions/meetings.mdonly.🤖 Generated with Claude Code
https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
Generated by Claude Code