Skip to content

Tell an empty meeting apart from one that cannot reach the gateway - #363

Open
lilseyi wants to merge 2 commits into
mainfrom
claude/multi-agent-orchestration-6cwo3c-empty-meeting-copy
Open

Tell an empty meeting apart from one that cannot reach the gateway#363
lilseyi wants to merge 2 commits into
mainfrom
claude/multi-agent-orchestration-6cwo3c-empty-meeting-copy

Conversation

@lilseyi

@lilseyi lilseyi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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:

This meeting has failed to reach your context several times. It is still on this device — try again, or copy your notes out.

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() in MeetingNoteScreen.tsx already checks session.state === "empty" before it ever reads record.rejection, and controller.end() already keeps a session with nothing captured out of the sync queue by folding it straight to empty. Both of those were right. What was not taught the same rule was recoverInterruptedRecordings() — the one thing configure() runs on the phone's nearest thing to a launch, for a session left recording/paused by a killed process. It folded every such session to failed with INTERRUPTED_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 failed session with nothing captured could then sit in the sync queue (a session metadata PUT is pending on any state change) and, on a genuinely bad connection, exhaust MAX_SYNC_ATTEMPTS and hit markSyncFailed'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)

  1. recoverInterruptedRecordings (apps/mobile/features/meetings/controller.ts) now checks hasNothingCaptured exactly as end() does. Nothing captured → end then empty (the only legal route to empty per MEETING_TRANSITIONS), with a new, honest INTERRUPTED_EMPTY_REASON. Something captured → unchanged: failed with INTERRUPTED_RECORDING_REASON and its Retry, exactly as before.
  2. 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 distinct NOTHING_CAPTURED code 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.
  3. MeetingNoteScreen.tsx renders NOTHING_CAPTURED the same way it renders state === "empty" — neutral tone, no "This meeting has not left the device" crit banner — for the case where that backstop is ever reached.
  4. A new decision record in 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 other Landing/Summary branches (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's MEETING_WRITE_SENTENCES / asGatewayError mapping is a carefully-argued, code-by-code attribution (see its own header) — not a "always blames X" pattern.
  • rejectionNotice()'s forbidden branch ("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's SyncChip ("Needs you") and MeetingRow's badges are correctly generic/neutral and already treat empty as its own non-error state.

No change to MEETING_TRANSITIONS, checkFinalizeTimeout/FINALIZE_TIMEOUT_MS, or hasNothingCaptured's own definition. recoverStaleFinalizes was not touched: a session that reached finalizing at all had a start/resume behind it, so pendingSteps would 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:

  • Reverted markSyncFailed to 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).
  • Reverted recoverInterruptedRecordings to the original unconditional fail1 FAIL (a recording killed within seconds — nothing captured — is empty, not failed).
  • Removed the NOTHING_CAPTURED branch in MeetingNoteScreen's Landing1 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 of meetingsController.test.ts uses 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 corrected Nothing 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 parallel jest load 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.md only.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz


Generated by Claude Code

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
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.

2 participants