From 0c35e456a1d453214bbae47f8a3ab535e3d5e2bd Mon Sep 17 00:00:00 2001 From: nickybmon Date: Thu, 2 Jul 2026 13:56:02 -0400 Subject: [PATCH 1/2] fix: expire stale meeting-boundary marker to unblock back-to-back detection The engine records a _last_completed_boundary after each meeting so the just-ended meeting's lingering WAL data can't be re-detected as the next active meeting. That marker carried a date-less seconds-since-midnight freshness floor and was only cleared once a concrete meeting id was tracked. Because the app runs 24/7 as a menu-bar process, a boundary set by an earlier/previous-day meeting survives into a later session. When its clock time is numerically above a new meeting's timestamps (e.g. yesterday 5pm vs this morning 9am), detect_active_meeting_id filters the new meeting out and returns None on every tick. Tracking never upgrades from empty, the boundary never clears, and back-to-back meetings pile into one accumulator until the user manually generates. Confirmed against the 2026-07-02 Brand Studio Crit logs (70 min of ACTIVE with empty tracking; active_id_self_healed from ""). Fix: stamp the boundary with a wall-clock set time and expire it after _BOUNDARY_EXPIRY_SECS (1h) via a new _active_boundary() accessor used at both detection read sites. 1h is far longer than the marker's real few-minute job yet guarantees no cross-session/overnight bleed. Also emit an active_empty_no_detection diag so the stuck state is visible in logs. Adds TestBoundaryExpiry regression tests (verified failing pre-fix). --- tests/test_back_to_back_meetings.py | 10 ++-- tests/test_engine_state_machine.py | 82 +++++++++++++++++++++++++++++ zoom_engine.py | 76 +++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 10 deletions(-) diff --git a/tests/test_back_to_back_meetings.py b/tests/test_back_to_back_meetings.py index 081d0a2..29f2ecf 100644 --- a/tests/test_back_to_back_meetings.py +++ b/tests/test_back_to_back_meetings.py @@ -1029,8 +1029,12 @@ def test_boundary_persists_until_tracking_is_set( import time as _time base_mtime = _time.time() - # Pre-stamp a boundary as if a previous meeting just finished. - engine._last_completed_boundary = ("just_ended_id_AA", 12 * 3600) + # Pre-stamp a boundary as if a previous meeting just finished — a + # FRESH set_at, since the just-ended meeting whose data still lingers + # is the exact case the boundary protects against. (A stale boundary + # is expired by `_active_boundary`; see TestBoundaryExpiry.) + boundary_set_at = _time.time() + engine._last_completed_boundary = ("just_ended_id_AA", 12 * 3600, boundary_set_at) # Tick 1: anchor. _drive_tick(engine, fake_origin, cfg, wal=synthetic_wal, @@ -1048,7 +1052,7 @@ def test_boundary_persists_until_tracking_is_set( "the next tick has no protection against detecting the " "just-ended meeting" ) - assert engine._last_completed_boundary == ("just_ended_id_AA", 12 * 3600) + assert engine._last_completed_boundary == ("just_ended_id_AA", 12 * 3600, boundary_set_at) # ── Engine: collect-entries self-heal when active_meeting_id is empty ──── diff --git a/tests/test_engine_state_machine.py b/tests/test_engine_state_machine.py index 575d4f2..f6eabff 100644 --- a/tests/test_engine_state_machine.py +++ b/tests/test_engine_state_machine.py @@ -6,6 +6,7 @@ """ import os import shutil +import time from unittest.mock import patch import pytest @@ -440,3 +441,84 @@ def test_non_blocked_id_still_activates(self, fake_origin, tmp_path, isolated_ca assert engine._get_state() == EngineState.ACTIVE, ( "a non-blocked meeting_id must still trigger IDLE→ACTIVE" ) + + +class TestBoundaryExpiry: + """Regression guard for the 2026-07-02 back-to-back deadlock. + + `_last_completed_boundary` carries a date-less seconds-since-midnight + freshness floor. Because the app runs 24/7 as a menu-bar process, a + boundary set by an earlier/previous-day meeting could outlive its purpose + and permanently suppress detection of a later meeting whose timestamps are + numerically below the stale floor — detection returned None every tick, + the empty tracked id never upgraded, and back-to-back meetings merged into + one note. The fix expires the boundary after `_BOUNDARY_EXPIRY_SECS`. + """ + + def test_active_boundary_expires_stale_entry(self): + engine = ZoomEngine() + # Boundary older than the expiry window → dropped and cleared. + engine._last_completed_boundary = ( + "OLD_MEETING_ID_xxxxxxxx==", 60000, + time.time() - (zoom_engine._BOUNDARY_EXPIRY_SECS + 60), + ) + assert engine._active_boundary() is None + assert engine._last_completed_boundary is None + + def test_active_boundary_keeps_fresh_entry(self): + engine = ZoomEngine() + fresh = ("RECENT_MEETING_xxxxxxxx==", 34200, time.time()) + engine._last_completed_boundary = fresh + assert engine._active_boundary() == fresh + assert engine._last_completed_boundary == fresh + + def _make_cfg(self): + engine = ZoomEngine() + return engine._get_cfg() + + def test_stale_boundary_not_passed_to_detection(self, fake_origin, tmp_path, isolated_cache): + """A stale boundary must NOT be handed to detect_active_meeting_id — + otherwise its floor filters out the genuinely-new meeting and tracking + stays empty forever.""" + wal = tmp_path / "stale-boundary.sqlite3-wal" + wal.write_bytes(b"x" * 1024) + engine = ZoomEngine() + cfg = self._make_cfg() + new_id = "NEW9amMeeting_xxxxxxxxxx==" + + # A boundary left over from a meeting hours ago, with a floor (16:40 = + # 60000s) numerically ABOVE this morning meeting's timestamps. + engine._last_completed_boundary = ( + "PRIOR_MEETING_xxxxxxxxxx==", 60000, + time.time() - (zoom_engine._BOUNDARY_EXPIRY_SECS + 60), + ) + + seen = [] + + def rec_detect(wal_path, *, exclude_meeting_id=None, freshness_floor_secs=None): + seen.append((exclude_meeting_id, freshness_floor_secs)) + return new_id + + size = wal.stat().st_size + # Tick 1: anchor (IDLE). + os.utime(wal, (1000.0, 1000.0)) + with patch.object(zoom_engine, "find_wal", return_value=wal), \ + patch.object(zoom_engine, "detect_active_meeting_id", side_effect=rec_detect): + engine._poll_once(fake_origin, cfg, idle_threshold=cfg.idle_threshold_secs) + + # Tick 2: size changed → detect runs, boundary should already be expired. + wal.write_bytes(b"x" * (size + 1)) + os.utime(wal, (1005.0, 1005.0)) + with patch.object(zoom_engine, "find_wal", return_value=wal), \ + patch.object(zoom_engine, "detect_active_meeting_id", side_effect=rec_detect): + engine._poll_once(fake_origin, cfg, idle_threshold=cfg.idle_threshold_secs) + + assert seen, "detect_active_meeting_id was never called" + # The last detection call must have been made with NO stale floor. + assert seen[-1] == (None, None), ( + f"stale boundary leaked into detection: {seen[-1]}" + ) + assert engine._get_state() == EngineState.ACTIVE + assert engine._read_tracking()[3] == new_id, ( + "engine failed to upgrade to the concrete meeting id" + ) diff --git a/zoom_engine.py b/zoom_engine.py index 91268fd..bdd1998 100644 --- a/zoom_engine.py +++ b/zoom_engine.py @@ -131,6 +131,21 @@ class EngineState: # without truncating the log. Treat <50% of last_size as a truncate signal. _TRUNCATE_RATIO = 0.5 +# How long a `_last_completed_boundary` stays in force. The boundary exists +# only to stop the JUST-ended meeting (whose WAL data lingers for a few +# minutes after it ends) from being re-detected as the next active meeting. +# That is a minutes-long concern. But the freshness_floor it carries is a +# date-less seconds-since-midnight value, and this app runs 24/7 as a menu +# bar process — so a boundary set by yesterday afternoon's meeting (floor +# ~5pm) survives into this morning and wrongly filters out today's 9am +# meeting (whose timestamps are numerically < the stale floor). Detection +# then returns None on every tick, the empty tracked id never upgrades, the +# boundary never clears, and back-to-back meetings pile into one bucket until +# the user manually generates. Expiring the boundary breaks that deadlock: +# 1h is generous for the legitimate purpose yet guarantees no overnight bleed. +# See the 2026-07-02 Brand Studio Crit incident. +_BOUNDARY_EXPIRY_SECS = 60 * 60 + class ZoomEngine: def __init__(self): @@ -171,7 +186,11 @@ def __init__(self): # `_last_generated_session`: the fingerprint there guards against # re-summarizing the same checkpoint, this guards against picking # up the wrong meeting when a NEW one starts immediately after. - self._last_completed_boundary: tuple[str, int] | None = None + # Shape: (meeting_id, freshness_floor_secs, set_at_epoch). The third + # element stamps when the boundary was recorded so `_active_boundary` + # can expire it (see _BOUNDARY_EXPIRY_SECS) — a stale boundary would + # otherwise permanently suppress detection of a later/next-day meeting. + self._last_completed_boundary: tuple[str, int, float] | None = None # Set by _generate_notes when it writes a placeholder note instead of # a final note. Read by the worker so it knows to keep the persisted @@ -325,6 +344,33 @@ def _latest_acc_ts_secs_for(self, meeting_id: str) -> int | None: latest = secs return latest if latest >= 0 else None + def _active_boundary(self) -> tuple[str, int, float] | None: + """Return `_last_completed_boundary` if still in force, else clear it. + + The boundary carries a date-less seconds-since-midnight freshness + floor whose only legitimate job is to keep the just-ended meeting from + being re-detected in the minutes after it ends. Because this app runs + for days as a menu-bar process, a boundary can otherwise outlive its + purpose and — once its clock time is no longer comparable to a later + or next-day meeting's timestamps — permanently suppress detection. + Expiring it after `_BOUNDARY_EXPIRY_SECS` breaks that deadlock + (2026-07-02 Brand Studio Crit incident). + """ + boundary = self._last_completed_boundary + if boundary is None: + return None + set_at = boundary[2] + if time.time() - set_at > _BOUNDARY_EXPIRY_SECS: + self._last_completed_boundary = None + self._emit_diag( + "boundary_expired", + meeting_id=boundary[0], + freshness_floor=boundary[1], + age_secs=int(time.time() - set_at), + ) + return None + return boundary + def _write_tracking(self, *, mtime=..., size=..., active_ts=..., meeting_id=..., session_mtime=...): with self._tracking_lock: if mtime is not ...: @@ -696,8 +742,10 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: ) exclude_id = None freshness_floor = None - if apply_boundary and self._last_completed_boundary is not None: - exclude_id, freshness_floor = self._last_completed_boundary + if apply_boundary: + boundary = self._active_boundary() + if boundary is not None: + exclude_id, freshness_floor, _ = boundary try: meeting_id = detect_active_meeting_id( wal, @@ -707,6 +755,16 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: except Exception: meeting_id = None + # Observability for the empty-tracking deadlock: if we're ACTIVE + # with no tracked id and detection still can't name a meeting, + # surface it — this is the state where back-to-back meetings merge. + if state == EngineState.ACTIVE and tracking_is_empty and not meeting_id: + self._emit_diag( + "active_empty_no_detection", + exclude_id=exclude_id or "", + freshness_floor=freshness_floor if freshness_floor is not None else -1, + ) + # Blocked-meeting guard: if the detected meeting_id is in the # user's block list, refuse the transition entirely and stay IDLE. # Normalize by stripping whitespace so copy-pasted IDs with a @@ -1168,7 +1226,9 @@ def worker(): if processing_meeting_id: latest = self._latest_acc_ts_secs_for(processing_meeting_id) if latest is not None: - self._last_completed_boundary = (processing_meeting_id, latest) + self._last_completed_boundary = ( + processing_meeting_id, latest, time.time(), + ) # Clean up the in-memory accumulator. Keep the persisted # disk snapshot if note generation failed — it's the source # of truth for the retry flow. @@ -1666,7 +1726,7 @@ def _recover_active_meeting_id(self, acc_snapshot: dict) -> str | None: # The boundary check mirrors detect_active_meeting_id: don't re-select # a meeting we just finished generating notes for. _FUTURE_BUF_R = 5 * 60 - boundary = self._last_completed_boundary # (exc_id, freshness_floor) or None + boundary = self._active_boundary() # (exc_id, floor, set_at) or None eligible = [] for mid in counts: @@ -1676,7 +1736,7 @@ def _recover_active_meeting_id(self, acc_snapshot: dict) -> str | None: if ts_secs > _now_secs_r + _FUTURE_BUF_R: continue # timestamp is impossibly far in the future → stale if boundary is not None: - exc_id, exc_floor = boundary + exc_id, exc_floor, _ = boundary if mid == exc_id and ts_secs <= exc_floor: continue # stale WAL data from the session we just finished eligible.append(mid) @@ -2032,7 +2092,9 @@ def worker(): except (ValueError, AttributeError): continue if latest_ts_secs >= 0: - self._last_completed_boundary = (meeting_id, latest_ts_secs) + self._last_completed_boundary = ( + meeting_id, latest_ts_secs, time.time(), + ) # Cleanup persisted snapshot — the note is now safe on disk. delete_persisted_accumulator(meeting_id) From 3f18b52041da612b848f1a9532bb8f1f89198f40 Mon Sep 17 00:00:00 2001 From: nickybmon Date: Thu, 2 Jul 2026 13:56:48 -0400 Subject: [PATCH 2/2] feat: defer abandoned-meeting note generation until the new meeting proves real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Case B abandoned-meeting path so it no longer fires a note the instant the tracked meeting_id changes. A single meeting whose id oscillates between two values would otherwise generate a premature note on every flip. Instead the abandoned snapshot is parked in _pending_abandoned and resolved on later ticks: - _maybe_fire_pending_abandoned: once the meeting we switched TO accumulates its own real content, the switch is a genuine back-to-back boundary and the abandoned note fires. - flap-back guard: if the score swings back to the parked id first, it was a single flapping meeting — restore it and generate nothing. - _flush_pending_abandoned: on idle / acc-stale generation, finalize any still-parked note so it isn't stranded. Extends tests/test_abandoned_meeting_generation.py to cover the deferral, flap-back restore, and flush paths. --- tests/test_abandoned_meeting_generation.py | 87 ++++++++++- zoom_engine.py | 159 +++++++++++++++++++-- 2 files changed, 228 insertions(+), 18 deletions(-) diff --git a/tests/test_abandoned_meeting_generation.py b/tests/test_abandoned_meeting_generation.py index 84b4a54..1b9da21 100644 --- a/tests/test_abandoned_meeting_generation.py +++ b/tests/test_abandoned_meeting_generation.py @@ -276,9 +276,15 @@ class TestCaseBDispatch: """Case B in `_poll_once` must snapshot + dispatch when the abandoned accumulator looks real, and fall back to delete-only when it doesn't.""" - def test_case_b_kicks_off_abandoned_generation_when_threshold_met( + def test_case_b_defers_then_fires_when_new_meeting_proves_real( self, fake_origin, synthetic_wal, isolated_cache, isolated_vault ): + """The 2026-06-30 single-meeting-flap fix changed Case B from + generate-immediately to defer-until-proven. When meeting B is detected, + meeting A's note must NOT fire yet — it's parked in `_pending_abandoned`. + Only once meeting B accumulates real content of its own (proving a + genuine back-to-back boundary, not an ID flap) does A's note fire. + """ engine = ZoomEngine() cfg = engine._get_cfg() @@ -299,13 +305,30 @@ def test_case_b_kicks_off_abandoned_generation_when_threshold_met( pre_count = len(engine._accumulated) assert pre_count >= 5, "fixture sanity: accumulator must have meeting A's entries" - # Tick 3: Case B — meeting B detected. Patch the abandoned-generation - # entry point so we can assert it was called with meeting A's data. - b_entries = list(_make_realistic_accumulator("MEETING-B==", count=2).values()) + # Tick 3: Case B — meeting B detected with only a couple of entries. + # Generation must NOT fire (could be a flap); A is parked pending. + b_entries_small = list(_make_realistic_accumulator("MEETING-B==", count=2).values()) with patch.object(ZoomEngine, "_trigger_abandoned_generation") as mock_dispatch: _drive_tick( engine, fake_origin, cfg, wal=synthetic_wal, - mtime=1010.0, size=12288, entries=b_entries, + mtime=1010.0, size=12288, entries=b_entries_small, + detected_meeting_id="MEETING-B==", + ) + mock_dispatch.assert_not_called() + + assert engine._pending_abandoned is not None, "meeting A must be parked" + assert engine._pending_abandoned["meeting_id"] == "MEETING-A==" + parked = engine._pending_abandoned["snapshot"] + assert len(parked) >= 5 + assert all(e["meeting_id"] == "MEETING-A==" for e in parked.values()) + + # Tick 4: meeting B accumulates real content of its own — a genuine + # back-to-back boundary. Now A's deferred note fires, exactly once. + b_entries_full = list(_make_realistic_accumulator("MEETING-B==", count=6).values()) + with patch.object(ZoomEngine, "_trigger_abandoned_generation") as mock_dispatch: + _drive_tick( + engine, fake_origin, cfg, wal=synthetic_wal, + mtime=1015.0, size=16384, entries=b_entries_full, detected_meeting_id="MEETING-B==", ) mock_dispatch.assert_called_once() @@ -317,14 +340,64 @@ def test_case_b_kicks_off_abandoned_generation_when_threshold_met( assert len(snapshot) >= 5 assert all(e["meeting_id"] == "MEETING-A==" for e in snapshot.values()) - # And the new meeting's accumulator should start fresh (the existing - # Case B clear-and-switch behavior must not regress). + assert engine._pending_abandoned is None, "pending must clear after firing" + + # The new meeting's accumulator holds only meeting B's data. with engine._accumulated_lock: ids_after = {e.get("meeting_id") for e in engine._accumulated.values()} assert ids_after <= {"MEETING-B=="}, ( f"new meeting's accumulator polluted with abandoned data: {ids_after}" ) + def test_case_b_flap_back_restores_without_generating( + self, fake_origin, synthetic_wal, isolated_cache, isolated_vault + ): + """The core 2026-06-30 fix: a single meeting whose ID oscillates + between two values must NOT generate a premature note. When the score + swings A -> B (B never proves real) -> A, the flap-back guard restores + meeting A and discards the phantom B, generating nothing. + """ + engine = ZoomEngine() + cfg = engine._get_cfg() + + # Anchor + populate meeting A. + _drive_tick( + engine, fake_origin, cfg, wal=synthetic_wal, + mtime=1000.0, size=4096, entries=[], + detected_meeting_id="MEETING-A==", + ) + a_entries = list(_make_realistic_accumulator("MEETING-A==", count=6).values()) + _drive_tick( + engine, fake_origin, cfg, wal=synthetic_wal, + mtime=1005.0, size=8192, entries=a_entries, + detected_meeting_id="MEETING-A==", + ) + + with patch.object(ZoomEngine, "_trigger_abandoned_generation") as mock_dispatch: + # Flip to phantom B (1 entry) — A parked, nothing generated. + b_entries = list(_make_realistic_accumulator("MEETING-B==", count=1).values()) + _drive_tick( + engine, fake_origin, cfg, wal=synthetic_wal, + mtime=1010.0, size=12288, entries=b_entries, + detected_meeting_id="MEETING-B==", + ) + assert engine._pending_abandoned is not None + + # Score swings back to A before B ever became real → flap-back. + _drive_tick( + engine, fake_origin, cfg, wal=synthetic_wal, + mtime=1015.0, size=16384, entries=a_entries, + detected_meeting_id="MEETING-A==", + ) + mock_dispatch.assert_not_called() + + assert engine._pending_abandoned is None, "flap-back must clear pending" + _, _, _, tracked = engine._read_tracking() + assert tracked == "MEETING-A==", "flap-back must restore meeting A as tracked" + with engine._accumulated_lock: + restored_ids = {e.get("meeting_id") for e in engine._accumulated.values()} + assert "MEETING-A==" in restored_ids, "meeting A's transcript must be restored" + def test_case_b_carries_forward_new_meetings_opening_lines( self, fake_origin, synthetic_wal, isolated_cache, isolated_vault ): diff --git a/zoom_engine.py b/zoom_engine.py index bdd1998..f98350d 100644 --- a/zoom_engine.py +++ b/zoom_engine.py @@ -192,6 +192,17 @@ def __init__(self): # otherwise permanently suppress detection of a later/next-day meeting. self._last_completed_boundary: tuple[str, int, float] | None = None + # Deferred abandoned-meeting note (the 2026-06-30 single-meeting-flap + # fix). When a mid-ACTIVE meeting_id change abandons a real-looking + # accumulator, we no longer generate its note immediately — a single + # meeting whose ID oscillates between two values would otherwise fire + # a premature note on every score flip. Instead we hold the abandoned + # snapshot here and only generate once the meeting we switched TO + # proves real (accumulates its own content). A flap swings the score + # back to this id before that happens, and the flap-back guard in + # Case B restores it instead. Shape: {"meeting_id": str, "snapshot": dict}. + self._pending_abandoned: dict | None = None + # Set by _generate_notes when it writes a placeholder note instead of # a final note. Read by the worker so it knows to keep the persisted # accumulator (for retry) rather than deleting it. @@ -894,15 +905,49 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: meeting_id=meeting_id, context="case_b", ) + elif ( + self._pending_abandoned is not None + and meeting_id == self._pending_abandoned["meeting_id"] + ): + # FLAP-BACK: we recently deferred abandoning this very + # meeting (held its snapshot, waiting for the meeting we + # switched to to prove real). The score has now swung + # back to it — proof that this is a SINGLE meeting whose + # ID oscillates between two values, not a back-to-back + # boundary. Restore it and discard the phantom we briefly + # tracked. No note is generated; the meeting continues. + restored = self._pending_abandoned["snapshot"] + self._pending_abandoned = None + self._write_tracking(meeting_id=meeting_id, session_mtime=mtime) + with self._accumulated_lock: + # Re-seed from the deferred snapshot. The WAL re-parse + # later this same tick re-adds anything written since. + self._accumulated = dict(restored) + acc_size = len(self._accumulated) + self._emit_diag( + "meeting_id_changed", + from_id=current_tracking_id, to_id=meeting_id, + reason="flap_back_restore", + restored_entries=acc_size, + ) + self._set_state( + EngineState.ACTIVE, + meeting_id=meeting_id, + accumulator_size=acc_size, + ) else: # Case B: real meeting change. Two sub-scenarios: # # 1) BACK-TO-BACK MEETINGS: the previous meeting really # happened, has a real accumulator with real - # conversation. Auto-generate its note now (the - # 2026-05-04 AEO GA fix) before clearing local - # state. Runs in a background thread so the new - # meeting's tracking proceeds without delay. + # conversation. Its note should be generated (the + # 2026-05-04 AEO GA fix). We now DEFER that decision + # rather than firing immediately (the 2026-06-30 + # single-meeting-flap fix): the note fires once the + # meeting we switched TO accumulates its own real + # content — see _maybe_fire_pending_abandoned. A + # genuine new meeting reaches that bar within a tick + # or two; a phantom flap-target never does. # # 2) MISIDENTIFICATION: scoring just corrected itself # after briefly tracking the wrong meeting (the @@ -927,13 +972,27 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: if v.get("meeting_id") == current_tracking_id } if self._abandoned_looks_real(strict_snapshot): - self._trigger_abandoned_generation( - current_tracking_id, abandoned_snapshot, - ) + # DEFER generation. If a DIFFERENT meeting is already + # pending, this switch supersedes it — flush the old + # pending now so its note isn't lost when we overwrite. + if ( + self._pending_abandoned is not None + and self._pending_abandoned["meeting_id"] + != current_tracking_id + ): + self._trigger_abandoned_generation( + self._pending_abandoned["meeting_id"], + self._pending_abandoned["snapshot"], + ) + self._pending_abandoned = { + "meeting_id": current_tracking_id, + "snapshot": abandoned_snapshot, + } # Note: do NOT delete_persisted_accumulator here — - # _trigger_abandoned_generation persists a fresh - # snapshot synchronously and cleans up after itself - # on success. + # the persisted snapshot is the durability boundary + # until the deferred decision resolves (fired by + # _maybe_fire_pending_abandoned or restored by the + # flap-back guard above). else: try: delete_persisted_accumulator(current_tracking_id) @@ -973,7 +1032,7 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: reason="active_reevaluation", abandoned_snapshot_size=len(abandoned_snapshot), strict_snapshot_size=len(strict_snapshot), - abandoned_auto_generated=self._abandoned_looks_real(strict_snapshot), + abandoned_deferred=self._abandoned_looks_real(strict_snapshot), carried_new_meeting_entries=acc_carry_size, ) self._set_state( @@ -1064,6 +1123,12 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: meeting_id=(self._read_tracking()[3] or ""), ) + # Resolve any deferred abandoned-meeting note now that the + # accumulator reflects this tick's content: if the meeting we + # switched to has accumulated real content of its own, the switch + # was a genuine back-to-back boundary and the abandoned note fires. + self._maybe_fire_pending_abandoned() + # Secondary idle trigger: fire generation when the accumulator # has been frozen for a long time even though the WAL file is # still being updated by Zoom checkpoint writes. This is the @@ -1098,6 +1163,7 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: acc_stale_secs=int(acc_stale_secs), threshold=acc_stale_threshold, ) + self._flush_pending_abandoned(reason="acc_stale") self._trigger_generate(origin, cfg) return @@ -1105,6 +1171,12 @@ def _poll_once(self, origin, cfg, idle_threshold: int) -> None: if state == EngineState.ACTIVE and last_active_ts is not None: idle_secs = now - last_active_ts if idle_secs >= idle_threshold: + # The tracked meeting has fallen idle. If a different + # meeting is still parked pending the flap/back-to-back + # decision, it was real — finalize it now before we + # generate or disarm the tracked one, so it isn't stranded. + self._flush_pending_abandoned(reason="idle") + # Belt-and-suspenders: don't re-summarize a meeting we # already finished. Zoom checkpoints the WAL after the # meeting ends, which mutates mtime/size and would @@ -1881,6 +1953,71 @@ def _abandoned_looks_real(snapshot: dict) -> bool: return True return False + def _maybe_fire_pending_abandoned(self) -> None: + """Generate a deferred abandoned-meeting note once the switch is proven. + + The 2026-06-30 single-meeting-flap fix: Case B no longer generates an + abandoned meeting's note the instant the tracked meeting_id changes, + because a single meeting whose ID oscillates between two values would + fire a premature note on every flip. Instead it parks the abandoned + snapshot in `self._pending_abandoned`. This method, called every poll + tick after the accumulator is refreshed, resolves the deferral: + + - If the meeting we switched TO has now accumulated real content of + its own (`_abandoned_looks_real` over its strict entries), the + switch was a genuine back-to-back boundary — fire the abandoned + note and clear the pending state. + - Otherwise leave it pending. A flap swings the score back to the + abandoned id first, and the flap-back guard in Case B restores it + without ever generating. A genuine new meeting clears this bar + within a tick or two of real speech. + + Idle/secondary-idle generation flushes any leftover pending note via + the same `_trigger_abandoned_generation` path (see `_flush_pending_abandoned`). + """ + pending = self._pending_abandoned + if not pending: + return + _, _, _, tracked_id = self._read_tracking() + if not tracked_id or tracked_id == pending["meeting_id"]: + return + with self._accumulated_lock: + new_strict = { + k: v for k, v in self._accumulated.items() + if v.get("meeting_id") == tracked_id + } + if self._abandoned_looks_real(new_strict): + self._emit_diag( + "abandoned_generation_promoted", + abandoned_id=pending["meeting_id"], + new_meeting_id=tracked_id, + new_entry_count=len(new_strict), + ) + self._trigger_abandoned_generation( + pending["meeting_id"], pending["snapshot"], + ) + self._pending_abandoned = None + + def _flush_pending_abandoned(self, reason: str) -> None: + """Generate any still-pending abandoned note before going idle. + + Reached when the engine decides to generate / disarm the currently + tracked meeting while a different meeting is still parked in + `_pending_abandoned` (the meeting we switched to ended or fell silent + before accumulating enough to promote the deferral on its own). The + parked meeting was real — finalize it rather than stranding it. + """ + pending = self._pending_abandoned + if not pending: + return + self._emit_diag( + "abandoned_generation_flushed", + abandoned_id=pending["meeting_id"], + reason=reason, + ) + self._trigger_abandoned_generation(pending["meeting_id"], pending["snapshot"]) + self._pending_abandoned = None + def _trigger_abandoned_generation(self, meeting_id: str, snapshot: dict) -> None: """Finalize an accumulator that Case B is about to abandon.