Skip to content

fix: stop tearing down meeting recordings when the observed identity changes - #375

Open
jamesagarside wants to merge 6 commits into
Muesli-HQ:mainfrom
jamesagarside:fix/meeting-source-rematch-after-session-rotation
Open

fix: stop tearing down meeting recordings when the observed identity changes#375
jamesagarside wants to merge 6 commits into
Muesli-HQ:mainfrom
jamesagarside:fix/meeting-source-rematch-after-session-rotation

Conversation

@jamesagarside

@jamesagarside jamesagarside commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

MeetingAutoStopPolicy.matches() compares a live candidate against the armed source by candidate ID, suppression ID, and normalized URL. It never consults the source's bundle ID.

MeetingMediaSessionTracker mints a fresh session ID once its 30s quiet window lapses. A dedicated meeting app (Teams, Zoom, Webex, FaceTime) has no meeting URL, so the sequence is:

  1. The call stops reporting microphone input for >30s — muted, or a transient input device reconfiguration.
  2. The tracker's session expires and the meeting reappears under a new ID.
  3. matches() compares new-ID against old-ID with no URL to fall back on → false, permanently.
  4. markSourceRecovered() can therefore never fire, so the "Meeting signal lost" warning cannot be dismissed by the meeting coming back.
  5. The warning auto-dismisses 30s later and a still-running meeting is finalized and summarized.

For any non-browser meeting this teardown is unrecoverable by construction — once the session ID rotates there is no path back. That matches the report in #303: "never auto-resumed even though the device was back", during a stretch where the microphone was demonstrably still in use by Teams.

What changed

Fall back to app identity when — and only when — the source has no URL and is not a browser. Browsers stay excluded because a single browser hosts many unrelated sessions, so its bundle ID is not a meeting identity; there is a test pinning that.

This deliberately does not touch the 20s disappearance grace or the 30s warning window. It only restores the ability of a dedicated app source to re-match, which is what makes the existing recovery path reachable.

Relationship to #368

Complementary rather than overlapping. #368 changes the response to signal loss for .detectedPrompt origins; this changes the matching that decides whether signal was lost at all. Both touch MeetingAutoStopPolicy.swift but different functions, and either can land first. Happy to rebase around whichever you prefer.

Note on CI coverage

MeetingAutoStopPolicyTests was on the ci_unsharded_test_suites.txt legacy allowlist, so none of its coverage has been running in CI. Assigned to the meetings shard and removed from the allowlist; scripts/test_ci_test_shards.sh passes.

Validation

Developed red-then-green rather than asserting after the fact:

  • The new rotation test fails against current main with exactly the expected mismatch (app:com.microsoft.teams2:session:1800000000 vs ...:1800000045, normalizedURL: nil), and passes after the change.
  • Both new negative tests — a different meeting app, and a browser-audio source with no URL — pass before and after, confirming the change is narrow and does not loosen browser matching.
  • swift test --filter MeetingAutoStopPolicyTests — 20 tests pass, including all 17 pre-existing.
  • bash scripts/run_ci_test_shard.sh meetings373 tests / 23 suites, all passing (up from 351/22, since this suite now actually runs).
  • bash scripts/test_ci_test_shards.sh — shard assignments verified.

Still open

Whether muting is what produces these >30s gaps is app-specific and I have not verified it, so I have not claimed it as the trigger. This change is deliberately independent of the cause: it fixes the fact that any gap longer than the quiet window is unrecoverable for a dedicated app. If it turns out meeting apps do drop kAudioProcessPropertyIsRunningInput while muted, then the deeper fix is for liveness to key on the call existing rather than on microphone capture — a larger change I did not want to bundle here.

Contribution certification

  • Every non-merge commit in this pull request has a valid
    Signed-off-by trailer from its author under the
    Developer Certificate of Origin.
  • I created this contribution or otherwise have the right to submit it
    under Muesli's
    MIT License.
  • I have obtained any permission required by an employer, client,
    institution, or other party that may have rights in this contribution.
  • I have identified all third-party code, models, datasets, media, and
    other assets introduced by this pull request, including their sources
    and licenses or terms.
  • I have disclosed material AI assistance and reviewed and tested the
    resulting changes.

Third-party materials

None.

AI assistance

Claude Code was used to locate the root cause, implement the change, and write the tests. All changes were reviewed and tested locally, including verifying the new test fails against unpatched code.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • Bug Fixes

    • Improved automatic meeting stopping when calendar events, browser tabs, or media-session identities change.
    • Improved recognition of dedicated meeting applications when meeting links are unavailable.
    • Prevented unrelated browser audio, calendar events, or different meeting applications from triggering an automatic stop.
  • Tests

    • Added coverage for calendar-event tracking, tab changes, disappearing audio, application rotation, and unrelated meeting candidates.
    • Updated test distribution for more reliable CI execution.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Meeting auto-stop matching now tracks calendar event identity, supports URL-less dedicated meeting applications by bundle ID, excludes browser bundle fallback, preserves identity through session stabilization, and routes meeting tests to the meetings CI shard.

Changes

Meeting auto-stop matching

Layer / File(s) Summary
Candidate identity propagation
native/MuesliNative/Sources/MuesliNativeApp/MeetingCandidateResolver.swift
MeetingCandidate stores calendarEventID, compares it for equality, and receives it through all candidate creation paths.
Auto-stop source identity
native/MuesliNative/Sources/MuesliNativeApp/MeetingAutoStopPolicy.swift
MeetingAutoStopSource stores and preserves calendarEventID. URL-based sources set it to nil.
Session identity persistence
native/MuesliNative/Sources/MuesliNativeApp/MeetingMediaSessionTracker.swift, native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift
Sessions initialize, update, and preserve calendar event identity during stabilization.
Matching rules and validation
native/MuesliNative/Sources/MuesliNativeApp/MeetingAutoStopPolicy.swift, native/MuesliNative/Tests/MuesliTests/MeetingAutoStopPolicyTests.swift
Matching uses calendar event identity and non-browser bundle identity for URL-less records. Tests cover identity changes, rejection cases, and audio disappearance.
Meeting test shard routing
scripts/ci_unsharded_test_suites.txt, scripts/run_ci_test_shard.sh
Meeting policy and media session tests run in the meetings shard instead of the unsharded suite.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MeetingCandidateResolver
  participant MeetingMediaSessionTracker
  participant MeetingAutoStopPolicy
  MeetingCandidateResolver->>MeetingMediaSessionTracker: provide candidate with calendarEventID
  MeetingMediaSessionTracker->>MeetingAutoStopPolicy: provide stabilized candidate
  MeetingAutoStopPolicy->>MeetingAutoStopPolicy: match calendar event or dedicated-app identity
Loading

Possibly related PRs

  • Muesli-HQ/muesli#246: Both PRs modify candidate resolution and attribution paths.
  • Muesli-HQ/muesli#350: This PR extends calendar identity propagation into session tracking and auto-stop matching.
  • Muesli-HQ/muesli#383: Both PRs modify meeting auto-stop policy, candidate resolution, and related tests.

Suggested reviewers: phequals7, rogersterling

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preserving meeting recordings when observed meeting identity changes.
Description check ✅ Passed The description covers the change, rationale, validation, certification, third-party materials, and AI assistance requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands meeting auto-stop identity matching so recordings can recover when media-session identities rotate, and propagates calendar identity through candidate stabilization.

  • Adds calendar-event and dedicated-app fallback matching.
  • Carries calendar event IDs through resolver and media-session tracker candidates.
  • Adds policy and tracker coverage to the meetings CI shard.

Confidence Score: 4/5

The PR does not yet appear safe to merge because both fallback identities can keep an existing recording attached to unrelated meeting activity.

Dedicated-app recovery still treats bundle equality as meeting identity across distinct calls, while snapshot-wide calendar attribution lets unrelated qualifying candidates refresh the armed source and prevent auto-stop.

Files Needing Attention: native/MuesliNative/Sources/MuesliNativeApp/MeetingAutoStopPolicy.swift; native/MuesliNative/Sources/MuesliNativeApp/MeetingCandidateResolver.swift

Important Files Changed

Filename Overview
native/MuesliNative/Sources/MuesliNativeApp/MeetingAutoStopPolicy.swift Adds calendar-event and dedicated-app fallback identities to auto-stop matching.
native/MuesliNative/Sources/MuesliNativeApp/MeetingCandidateResolver.swift Propagates the active snapshot calendar event onto candidates from browser, app-audio, and fallback branches.
native/MuesliNative/Sources/MuesliNativeApp/MeetingMediaSessionTracker.swift Preserves and refreshes calendar identity while stabilizing media-session candidates.
native/MuesliNative/Sources/MuesliNativeApp/MuesliController.swift Supplies calendar event identity when arming auto-stop from scheduled meeting links.
native/MuesliNative/Tests/MuesliTests/MeetingAutoStopPolicyTests.swift Adds positive and negative coverage for rotated app sessions and calendar-based matching.
native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift Verifies calendar identity preservation and replacement across stabilized session refreshes.
scripts/run_ci_test_shard.sh Adds the auto-stop policy and media-session tracker suites to the meetings shard.

Reviews (7): Last reviewed commit: "fix: let auto-stop re-match a dedicated ..." | Re-trigger Greptile

@jamesagarside

jamesagarside commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Flagging a trade-off I under-stated in the description, rather than leaving it to be found in review.

Falling back to app identity makes the match broader: any candidate from the same dedicated app now matches the armed source. The failure mode that introduces is the mirror of the one it fixes — if a meeting app keeps its microphone and speaker streams open after a call ends, the candidate keeps matching, lastSeenAt keeps refreshing, and the recording may never auto-stop. Related: leaving one call and joining another in the same app now continues the existing recording rather than treating it as a new meeting.

I still think that is the right direction, for three reasons:

  1. It only affects auto-started recordings — .manual never arms auto-stop at all.
  2. Explicit stop is always available, so the cost is a recording that runs long rather than one that is lost. The current failure loses transcript from a live meeting, which is unrecoverable.
  3. The existing behaviour is not a conservative default — it is unrecoverable by construction once the session ID rotates, which is the part I would argue is the actual bug.

If you would rather bound it, the narrow version is to require that the app also still be running (a sourcePID liveness check) before the identity fallback applies. Happy to add that if you prefer a tighter guard.

On process: I am conscious this touches the same file as #368. If you would rather have one change here than two, I am glad to close this and fold the matching fix into that PR, or rebase on top of it once it lands — whichever keeps your review queue smaller. No attachment to it being a separate PR.

@jamesagarside

Copy link
Copy Markdown
Contributor Author

I've pushed a second commit here, because a real-world failure turned out to be the same defect with a different identity — and I'd rather widen this PR than open a third one.

What happened

A "Join & Record" recording was torn down 44 seconds in, with the "Meeting signal lost" prompt, while the call was still going. It isn't a one-off: the same user has 9 recordings under two minutes going back several weeks.

Why

matches() compares the armed source against the live candidate using candidateID, suppressionID and normalizedURL. Every one of those describes how the meeting is currently being observed, and each can change while the same call continues:

  • normalizedURL is only present while the call's browser tab is frontmost. BrowserMeetingActivityCollector reads kAXDocumentAttribute per window and the ScriptingBridge activeTab, so a meeting in a background tab reads as .noMeeting — which also removes the TTL cache entry rather than letting it age out.
  • candidateID and suppressionID are audio-session IDs from appAudioSessionID(forBundleID:prefix:now:), which mints a fresh ID once appAudioSessionIdleTimeout (10s) lapses without a refresh. The browserMeeting.isFocused branch of resolve() never calls it, so a focused tab with no active input process — muted, for instance — lets the session expire.

When any of that happens, MeetingCandidateResolver still resolves the meeting, through the calendar-keyed fallback (id: "cal:\(calendarEvent.id)"). The candidate is right there, attributed to the very event the recording was started for. matches() just can't see it, so the grace period elapses and the recording stops.

"Join & Record" is hit hardest because it arms auto-stop from MeetingAutoStopSource(meetingURL:), so the room URL is the only identity the source has to begin with.

The change

Carry the active calendar event ID on MeetingCandidate, learn it into MeetingAutoStopSource via the existing refined(with:) step, and match on it.

This is tighter than the app-identity fallback in the first commit: it matches only when both sides agree on the same calendar event, so it can't bleed into an unrelated meeting, and a source with no calendar event behaves exactly as before. It's also robust to all three triggers above rather than any one of them, which matters because I could not prove from the user's data which one fired — only that every identity the matcher relies on is derived from the observation rather than the meeting.

Verified by mutation: dropping the new match arm fails exactly the two behavioural tests and leaves the three negative-control tests passing.

Known gap

A browser meeting with no calendar event still has no stable identity, so backgrounding its tab can still tear it down. Fixing that means either treating a background tab as a live meeting or keying liveness on the browser still holding the mic — both change what counts as a live meeting for auto-start too, which is a much bigger blast radius than I'd want in this PR. Happy to follow up separately if you think that's worth doing.

Still glad to reshape this however suits you — including splitting the two commits back apart if you'd rather review them independently.

@jamesagarside jamesagarside changed the title fix: keep tracking a dedicated meeting app after its media session ID rotates fix: stop tearing down meeting recordings when the observed identity changes Aug 8, 2026
MeetingAutoStopPolicy.matches() compares a live candidate against the
armed source by candidate ID, suppression ID, and normalized URL. It
never consults the source's bundle ID.

MeetingMediaSessionTracker mints a fresh session ID once its 30s quiet
window lapses. A dedicated meeting app has no meeting URL, so when a
call briefly stops reporting microphone input -- being muted, or a
transient input device reconfiguration -- it reappears under an ID that
can never match the armed source again. matches() returns false
permanently, markSourceRecovered() can never fire, so the signal-loss
warning cannot be dismissed by the meeting coming back, and 30s later a
still-running meeting is finalized. The teardown is unrecoverable by
construction for any non-browser meeting.

Fall back to app identity when the source has no URL and is not a
browser. Browsers stay excluded because one browser hosts many unrelated
sessions, so its bundle ID is not a meeting identity.

Also assign MeetingAutoStopPolicyTests to the meetings CI shard. It sat
on the legacy-unsharded allowlist, so none of its coverage ran in CI.

Signed-off-by: James Garside <james.garside@elastic.co>
Auto-stop matched a recording's source against a live candidate using only
identities that describe how the meeting is currently being observed:
the candidate ID, the suppression ID and the normalized room URL.

All three can change while the same call is still running. The room URL is
only reported while the call's browser tab is frontmost, and the candidate
and suppression IDs are audio-session IDs that rotate once their idle
timeout lapses. When that happens, MeetingCandidateResolver still resolves
the meeting -- via its calendar-keyed fallback -- but matches() rejects it,
so the disappearance grace period elapses, "Meeting signal lost" is shown
and the recording is stopped mid-meeting.

This hits "Join & Record" hardest, because that path arms auto-stop from
the calendar event's join URL alone, so the URL is the only identity the
source starts with.

Carry the active calendar event ID on MeetingCandidate, learn it into
MeetingAutoStopSource through the existing refinement step, and match on
it. The match is scoped to a single calendar event, so it cannot bleed
into an unrelated meeting, and a source with no calendar event behaves
exactly as before.

Signed-off-by: James Garside <james.garside@elastic.co>
@jamesagarside
jamesagarside force-pushed the fix/meeting-source-rematch-after-session-rotation branch from 0204e01 to 61746e7 Compare August 10, 2026 09:47
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

MeetingMediaSessionTracker.stabilize rebuilds the candidate to swap in the
stable session identity, but it did not carry calendarEventID across. The
field defaults to nil, so it was dropped silently.

stabilize sits between resolve and the auto-stop matcher
(MeetingMonitor.swift), so the stripped candidate is what reaches
MeetingAutoStopPolicy.matches. matchesCalendarEventIdentity could therefore
never fire for a media-backed candidate, and every candidate on the calendar
path is media-backed: resolve only reaches the calendar fallbacks once
hasMediaActivity holds. The match arm added in 0204e01 was unreachable in
practice.

The session now carries the calendar event the same way it already carries
url and meetingTitle, preferring the newest observation and falling back to
what the session already knew.

MeetingMediaSessionTrackerTests sat on ci_unsharded_test_suites.txt and never
ran in required CI, which is why this was not caught. Moved into the meetings
shard.

Signed-off-by: James Garside <james.garside@elastic.co>
@jamesagarside

Copy link
Copy Markdown
Contributor Author

Rebased onto current main, and pushed a third commit fixing a defect in this
PR's own second commit.

matchesCalendarEventIdentity was unreachable.
MeetingMediaSessionTracker.stabilize rebuilds the candidate to swap in the
stable session identity, and it did not carry calendarEventID across. The
field defaults to nil, so it was dropped silently — no compiler error, no test
failure.

stabilize sits between resolve and the auto-stop matcher
(MeetingMonitor.swift), so the stripped candidate is what reaches
MeetingAutoStopPolicy.matches. The match arm added in 0204e01f could
therefore never fire for a media-backed candidate — and on the calendar path
every candidate is media-backed, because resolve only reaches the calendar
fallbacks once hasMediaActivity holds. The commit was inert.

The session now carries the calendar event the same way it already carries url
and meetingTitle: prefer the newest observation, fall back to what the session
already knew.

Why CI did not catch it. MeetingMediaSessionTrackerTests was on
scripts/ci_unsharded_test_suites.txt, so it never ran in required CI. Moved
into the meetings shard, alongside the two suites this PR already moved.

Added a regression test that asserts the session identity is rewritten while the
calendar attribution survives — it fails against the previous commit with
result?.calendarEventID → nil.

Full suite: 1546 tests across 150 suites, passing.

Separately, field testing on 2026-08-08 found the muting hypothesis in the
original description does not hold: a two-minute Zoom mute during an active
recording left the audio process at IsRunningInput=1 / IsRunningOutput=1
throughout, and the recording survived. Session-ID rotation from an idle-timeout
lapse is still a real failure mode and this PR still addresses it, but muting is
not the trigger that produces it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift (1)

152-194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the existing-session update path.

This test covers session creation and candidate rebuilding. It does not execute session.calendarEventID = candidate.calendarEventID ?? session.calendarEventID at Line 71. Add a second stabilization call for the same session key. Assert that a non-nil calendar event ID replaces the stored ID. Then pass a candidate without an event ID and assert that the stored ID remains.

As per coding guidelines, audio lifecycle changes should cover state-machine transitions and stale callbacks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift`
around lines 152 - 194, Extend stabilizePreservesCalendarEventID to call
stabilize again for the same session key, first with a candidate containing a
different non-nil calendarEventID and assert the stored attribution is replaced,
then with a candidate lacking calendarEventID and assert the previously stored
ID remains unchanged. Ensure these calls exercise the existing-session update
path rather than creating a new session.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift`:
- Around line 152-194: Extend stabilizePreservesCalendarEventID to call
stabilize again for the same session key, first with a candidate containing a
different non-nil calendarEventID and assert the stored attribution is replaced,
then with a candidate lacking calendarEventID and assert the previously stored
ID remains unchanged. Ensure these calls exercise the existing-session update
path rather than creating a new session.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c30d48e8-0c8d-4183-a842-d78930249506

📥 Commits

Reviewing files that changed from the base of the PR and between 61746e7 and 35361a3.

📒 Files selected for processing (4)
  • native/MuesliNative/Sources/MuesliNativeApp/MeetingMediaSessionTracker.swift
  • native/MuesliNative/Tests/MuesliTests/MeetingMediaSessionTrackerTests.swift
  • scripts/ci_unsharded_test_suites.txt
  • scripts/run_ci_test_shard.sh
💤 Files with no reviewable changes (1)
  • scripts/ci_unsharded_test_suites.txt

MeetingCandidateResolver.resolve attributes any media activity to the current
calendar event once one exists, and its last fallback returns a bare
'cal:<id>' candidate with no meeting app at all. Calendar selection does not
check for a join link, so a reminder or a placeholder is an ordinary calendar
event as far as detection is concerned.

matchesCalendarEventIdentity therefore let a placeholder hold a recording open
for the event's whole duration on the strength of unrelated microphone use —
a dictation tool is enough. That was latent while the calendar arm was inert;
the previous commit made it reachable.

Require attributed meeting audio or a room URL on the candidate. Every calendar
branch backed by real meeting media carries one of those; only the bare
fallback carries neither.

This is the auto-stop half. Filtering placeholder events out of detection
itself is a broader behaviour change and is handled separately, behind a
setting.

Signed-off-by: James Garside <james.garside@elastic.co>
The existing test only exercises session creation. Every candidate after the
first takes the refresh path instead, where the stored event id is updated
rather than set, and that path had no coverage.

Both halves matter. A live meeting emits candidates continuously and the
cached calendar event drops off whenever it lapses mid-call, so treating
absence as a new answer would clear the id and take
matchesCalendarEventIdentity dark for the rest of the recording — the same
failure as dropping it on rebuild, arriving later in the call.

Verified by mutation: clearing on absence fails the preserve assertion,
removing the update fails both. Neither mutation is caught by the existing
creation-path test.

Signed-off-by: James Garside <james.garside@elastic.co>
@jamesagarside

Copy link
Copy Markdown
Contributor Author

Good catch on the untested path. Added in 927bd893.

The existing test only exercised session creation. Every candidate after the
first takes the refresh path in updateSession, where the stored event id is
updated rather than set, and that had no coverage at all.

The new test drives three stabilize calls against one session: a newer event id
replaces the stored one, and a later candidate carrying no event id leaves it
intact. The second half is the one with teeth — a live meeting emits candidates
continuously and the cached calendar event drops off whenever it lapses
mid-call, so treating absence as a new answer would clear the id and take
matchesCalendarEventIdentity dark for the rest of the recording. Same failure
as dropping it on rebuild, just arriving later in the call.

Mutation-verified: clearing on absence fails the preserve assertion, removing
the update line fails both. Neither mutation is caught by the pre-existing
creation-path test, which is the gap you flagged.

"Join & Record" arms auto-stop from the calendar event's join link, which
leaves the source with URL-derived identity only: no bundle id, no calendar
event. A dedicated meeting app's candidates carry no URL, so nothing matched
once the observed session id rotated, and the recording was torn down while
the call was still running.

The source could not recover on its own. It only refines after a successful
match, and the only candidates that match a URL are browser ones — so it could
never learn the meeting app's bundle id, and the dedicated-app arm added in
9a11dd1 was unreachable for this entire entry point.

Two changes, each needed:

- Carry the calendar event id into the source when arming from a join link.
  Both sides derive it from the same EventKit `eventIdentifier`, so the event
  is an identity they can agree on from the first candidate, without waiting
  for a refinement that never comes.

- Key the dedicated-app arm off the *candidate* having no URL rather than the
  source. `refined` never clears `normalizedURL`, so requiring the source to
  have none disabled that arm permanently for every Join & Record recording.
  The browser exclusion still carries the original intent.

Verified by mutation, separately for each: dropping the event id at arm time
fails only the join-link calendar test; restoring the source-URL guard fails
only the rotation test. Negative controls — a different calendar event, and an
unrelated browser session on a source that learned a browser — keep passing.
Full suite 1558/1558. MeetingAutoStopPolicyTests is assigned to a real CI
shard, so these run in required CI.

Signed-off-by: James Garside <james.garside@elastic.co>
@jamesagarside

Copy link
Copy Markdown
Contributor Author

Pushed 8e86b18d after a live reproduction last night with per-process
CoreAudio sampling running throughout. It exposed a gap that made the
dedicated-app fix in 9a11dd1b unreachable for the entry point most
recordings actually use.

22:01:03  Zoom   in=0 out=0 -> in=1 out=1   call joined
22:04:31  Muesli in=0       -> in=1         recording starts
22:05:55  Muesli in=1       -> in=0         torn down 84s in
23:07:42  Zoom   in=1 out=1 -> in=0 out=0   call actually ends

Zoom held in=1 out=1 continuously across the teardown — not one transition
between 22:01:03 and 23:07:42. The call never stopped qualifying, so this is
not signal loss. The candidate was there; matches() stopped recognising it.

The recording was started from a "Join & Record" notification, which arms via
MeetingAutoStopSource(meetingURL:). That leaves the source with URL-derived
identity only — no bundle id, no calendar event. A dedicated meeting app's
candidates carry no URL, so the URL arm cannot match them, and:

  • matchesDedicatedAppIdentity opened with guard source.normalizedURL == nil,
    and refined never clears normalizedURL (normalizedURL ?? candidate.url).
    So for every Join & Record recording that arm was disabled for the life of
    the recording.
  • The source could not rescue itself by refining, either. It only refines after
    a successful match, and the only candidates that match a URL are browser
    ones — so it could never learn the meeting app's bundle id.

Dropping the guard alone would therefore have been inert: the arm also needs
source.sourceBundleID, which a URL-armed source can only ever acquire as a
browser bundle id, which the browser exclusion then correctly rejects. So
both halves are here:

  1. Carry the calendar event id into the source when arming from a join link.
    Both sides derive it from the same EventKit eventIdentifier, so it is an
    identity they can agree on from the first candidate rather than waiting on a
    refinement that never arrives. This is the half that reaches production.
  2. Key the dedicated-app arm off the candidate having no URL rather than the
    source. The browser exclusion still carries the original intent.

Mutation-verified separately: dropping the event id at arm time fails only the
join-link calendar test; restoring the source-URL guard fails only the rotation
test. Negative controls — a different calendar event, and an unrelated browser
session on a source that learned a browser — keep passing. Full suite 1558/1558.

One thing I cannot show from here: which of the two arming paths produced last
night's teardown. Reading Muesli's own os_log needs Full Disk Access this
account does not have, so I can distinguish the code paths but not confirm
which one ran. The gap above is a property of the code and holds regardless.

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.

1 participant