From 7e18122376f0671d9875fc4c59905ce82c997978 Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Sun, 27 Sep 2026 22:02:19 +0200 Subject: [PATCH 1/3] fix(meetings): preserve capture end through processing retries --- .../client/src/lib/meeting-controls.test.ts | 16 ++++- Frontend/client/src/lib/meeting-controls.ts | 4 +- Frontend/package-lock.json | 4 +- Frontend/package.json | 2 +- docs/ARCHITECTURE.md | 4 ++ src/data/meeting_store.py | 12 +++- src/version.py | 2 +- src/web_api.py | 5 ++ tests/data/test_meeting_store.py | 70 +++++++++++++++++++ tests/test_meeting_api.py | 46 ++++++++++++ 10 files changed, 153 insertions(+), 12 deletions(-) diff --git a/Frontend/client/src/lib/meeting-controls.test.ts b/Frontend/client/src/lib/meeting-controls.test.ts index 6f21d094..8f1e0a05 100644 --- a/Frontend/client/src/lib/meeting-controls.test.ts +++ b/Frontend/client/src/lib/meeting-controls.test.ts @@ -25,11 +25,21 @@ test("Meeting controls are exposed only for recording and paused source states", } }); -test("processing clocks freeze at endedAt while active states use the live clock", () => { - const now = Date.parse("2026-07-17T10:05:00.000Z"); +test("stopped clocks retain the capture end even when processing resumes days later", () => { + const now = Date.parse("2026-07-19T22:00:00.000Z"); const endedAt = "2026-07-17T10:04:00.000Z"; - for (const state of ["stopping", "finalizing", "analyzing"] as const) { + for (const state of [ + "stopping", + "finalizing", + "analyzing", + "ready", + "capture_failed", + "finalization_failed", + "analysis_failed", + "interrupted", + "discarded", + ] as const) { assert.equal(meetingTimerNowMs(state, endedAt, now), Date.parse(endedAt)); } assert.equal(meetingTimerNowMs("recording", endedAt, now), now); diff --git a/Frontend/client/src/lib/meeting-controls.ts b/Frontend/client/src/lib/meeting-controls.ts index 0af72b08..00a9ae49 100644 --- a/Frontend/client/src/lib/meeting-controls.ts +++ b/Frontend/client/src/lib/meeting-controls.ts @@ -15,9 +15,9 @@ export function meetingControlVisibility(state: MeetingState): MeetingControlVis }; } -/** Freeze processing-state clocks at the durable capture end boundary. */ +/** Freeze every stopped clock at the durable capture end boundary. */ export function meetingTimerNowMs(state: MeetingState, endedAt: string | null, nowMs: number): number { - if (!["stopping", "finalizing", "analyzing"].includes(state) || !endedAt) { + if (["starting", "recording", "paused"].includes(state) || !endedAt) { return nowMs; } const endedAtMs = new Date(endedAt).getTime(); diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 5db4e467..427166b1 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "scriber", - "version": "0.5.124", + "version": "0.5.125", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scriber", - "version": "0.5.124", + "version": "0.5.125", "license": "MIT", "dependencies": { "@carrot-kpi/switzer-font": "^0.1.0", diff --git a/Frontend/package.json b/Frontend/package.json index ebef0bb8..efc6ea16 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -1,6 +1,6 @@ { "name": "scriber", - "version": "0.5.124", + "version": "0.5.125", "type": "module", "license": "MIT", "engines": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd6d59bd..7130aea5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -431,6 +431,10 @@ bytes. explicit `crash-recovery` gap. A process exit during `stopping` or `finalizing` becomes `finalization_failed` instead, so the UI retries from saved audio and never offers to append new capture to a stopped meeting. + `endedAt` records the capture end, never the processing completion time. + Successful Stop captures this boundary before recorder/provider draining; + finalization, analysis, retries, and recovery preserve it. Only an actual + transition back to recording clears it so the next Stop records a new end. A completed Meeting in either `ready` or `analysis_failed` may be processed again from retained evidence. Speaker-only refresh never calls an STT provider: it verifies the persisted Opus playback asset and its SHA-256, diff --git a/src/data/meeting_store.py b/src/data/meeting_store.py index a42f539f..0930ec73 100644 --- a/src/data/meeting_store.py +++ b/src/data/meeting_store.py @@ -1427,6 +1427,7 @@ def transition( error_message: str = "", capture_metadata: dict[str, Any] | None = None, analysis_model: str | None = None, + capture_ended_at: datetime | None = None, ) -> dict[str, Any]: if new_state not in MEETING_STATES: raise InvalidMeetingTransition(f"Unknown meeting state: {new_state}") @@ -1435,9 +1436,14 @@ def transition( raise InvalidMeetingTransition(f"Cannot transition {current['state']} to {new_state}.") now = _utc_now() started_at = now if new_state == "recording" and not current.get("startedAt") else current.get("startedAt") - ended_at = ( - now if new_state in TERMINAL_STATES or new_state in {"stopping", "finalizing"} else current.get("endedAt") - ) + # This is the capture boundary, not the processing completion time. + # Only actual capture resumption may reopen it; retries and recovery + # must retain the original end even when processing takes days. + ended_at = current.get("endedAt") + if new_state == "recording": + ended_at = None + elif ended_at is None and (new_state in TERMINAL_STATES or new_state in {"stopping", "finalizing"}): + ended_at = capture_ended_at.astimezone(UTC).isoformat().replace("+00:00", "Z") if capture_ended_at else now metadata = capture_metadata if capture_metadata is not None else current.get("captureMetadata", {}) # A progress value belongs to exactly one processing run. Preserve the # finalization -> analysis hand-off because it is one continuous diff --git a/src/version.py b/src/version.py index 9ba3290a..04b58163 100644 --- a/src/version.py +++ b/src/version.py @@ -3,7 +3,7 @@ import os import re -__version__ = "0.5.124" +__version__ = "0.5.125" _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") diff --git a/src/web_api.py b/src/web_api.py index 2c9f4d71..fb5bbb69 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -16776,6 +16776,9 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome: ) ) + # Freeze a successful Stop before recorder/provider draining can + # delay persistence of the state transition. + capture_ended_at = datetime.now(UTC) if command == "audioMeetingStop" else None native_payload = response.get("payload") if isinstance(response.get("payload"), dict) else {} native_stop = _meeting_native_stop_snapshot(native_payload) if native_stop: @@ -16838,6 +16841,7 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome: error_code=failure_code, error_message=failure_message, capture_metadata=capture_metadata, + capture_ended_at=capture_ended_at, ) ) pending_cancel = pending_cancel or transition_cancel @@ -16865,6 +16869,7 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome: meeting_id, target_state, capture_metadata=capture_metadata, + capture_ended_at=capture_ended_at, ) ) pending_cancel = pending_cancel or transition_cancel diff --git a/tests/data/test_meeting_store.py b/tests/data/test_meeting_store.py index bd814be2..43fff56f 100644 --- a/tests/data/test_meeting_store.py +++ b/tests/data/test_meeting_store.py @@ -43,6 +43,76 @@ def create_request(**overrides): return MeetingCreate(**values) +@pytest.mark.parametrize("paused", [False, True]) +def test_capture_end_survives_processing_retries_recovery_and_reprocessing(store, monkeypatch, paused): + now = "2026-09-25T07:04:46Z" + monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now) + meeting_id = store.create(create_request())["id"] + store.transition(meeting_id, "recording") + if paused: + store.transition(meeting_id, "paused") + now = "2026-09-25T11:23:38Z" + stopped = store.transition(meeting_id, "stopping") + assert stopped["endedAt"] == now + + now = "2026-09-27T18:58:09Z" + for state in ("stopping", "finalizing"): + assert store.transition(meeting_id, state)["endedAt"] == stopped["endedAt"] + assert store.recover_interrupted() == 1 + assert store.get(meeting_id)["endedAt"] == stopped["endedAt"] + for state in ( + "finalizing", + "finalization_failed", + "finalizing", + "analyzing", + "analysis_failed", + "analyzing", + "ready", + ): + result = store.transition(meeting_id, state) + assert result["endedAt"] == stopped["endedAt"] + assert result["updatedAt"] == now + reprocessing = store.reserve_full_reprocess( + meeting_id, final_provider="soniox_async", analysis_model="test-model", voice_library_enabled=False + ) + assert reprocessing["endedAt"] == stopped["endedAt"] + for state in ("ready", "analyzing", "ready", "discarded"): + assert store.transition(meeting_id, state)["endedAt"] == stopped["endedAt"] + + +def test_resumed_capture_reopens_end_but_keeps_original_start(store, monkeypatch): + now = "2026-09-25T07:00:00Z" + monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now) + meeting_id = store.create(create_request())["id"] + original = store.transition(meeting_id, "recording") + now = "2026-09-25T08:00:00Z" + assert store.recover_interrupted() == 1 + assert store.get(meeting_id)["endedAt"] == now + + now = "2026-09-25T09:00:00Z" + resumed = store.transition(meeting_id, "recording") + assert resumed["startedAt"] == original["startedAt"] + assert resumed["endedAt"] is None + now = "2026-09-25T11:00:00Z" + stopped = store.transition(meeting_id, "stopping") + assert stopped["endedAt"] == now + now = "2026-09-27T19:00:00Z" + assert store.transition(meeting_id, "finalizing")["endedAt"] == stopped["endedAt"] + + +@pytest.mark.parametrize("state", ["stopping", "capture_failed"]) +def test_capture_end_uses_native_stop_time_before_delayed_cleanup(store, monkeypatch, state): + now = "2026-09-25T07:00:00Z" + monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now) + meeting_id = store.create(create_request())["id"] + store.transition(meeting_id, "recording") + native_end = datetime(2026, 9, 25, 11, 23, 38, tzinfo=UTC) + now = "2026-09-27T18:58:09Z" + stopped = store.transition(meeting_id, state, capture_ended_at=native_end) + assert stopped["endedAt"] == "2026-09-25T11:23:38Z" + assert store.transition(meeting_id, "finalizing")["endedAt"] == stopped["endedAt"] + + def test_origin_is_first_class_without_fabricating_consent(store: MeetingStore): captured = store.create(MeetingCreate(title="Private call")) assert captured["origin"] == "captured" diff --git a/tests/test_meeting_api.py b/tests/test_meeting_api.py index 283c7544..47cd9304 100644 --- a/tests/test_meeting_api.py +++ b/tests/test_meeting_api.py @@ -2248,6 +2248,52 @@ async def release_audio(changed_controller, released_claim): database._close_all_connections() +@pytest.mark.asyncio +async def test_stop_keeps_native_capture_end_through_delayed_provider_cleanup(monkeypatch, tmp_path): + controller, store, meeting, recorder = _recording_meeting_control_controller( + monkeypatch, tmp_path, "stop-capture-end.db" + ) + controller._persistent_audio_claim = _test_audio_claim("meeting", meeting["id"]) + native_end = datetime(2026, 9, 25, 11, 23, 38, tzinfo=UTC) + clock = native_end + + class Clock: + @staticmethod + def now(_tz): + return clock + + class DelayedLiveTranscriber(FakeLiveTranscriber): + async def stop(self): + nonlocal clock + assert recorder.stop_count == 1 + clock = datetime(2026, 9, 27, 18, 58, 9, tzinfo=UTC) + await super().stop() + + async def release_audio(changed_controller, _claim): + changed_controller._persistent_audio_claim = None + return True + + controller._meeting_live_transcribers[meeting["id"]] = DelayedLiveTranscriber() + monkeypatch.setattr(web_api, "datetime", Clock) + monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: clock.isoformat().replace("+00:00", "Z")) + monkeypatch.setattr( + web_api, "call_shell_ipc", lambda *_args, **_kwargs: {"success": True, "payload": {"stopped": True}} + ) + monkeypatch.setattr(web_api, "_release_persistent_audio", release_audio) + app = web_api.create_app(controller) + handler = _route_handler(app, "POST", "/api/meetings/{id}/stop") + try: + response = await handler(_DirectRequest(app, meeting_id=meeting["id"])) + assert response.status == 202 + result = json.loads(response.body) + assert result["state"] == "finalizing" + assert result["endedAt"] == "2026-09-25T11:23:38Z" + assert result["updatedAt"] == "2026-09-27T18:58:09Z" + assert store.get(meeting["id"])["endedAt"] == result["endedAt"] + finally: + database._close_all_connections() + + @pytest.mark.asyncio async def test_stop_reserves_finalizer_before_committing_finalizing(monkeypatch, tmp_path): controller, store, meeting, recorder = _recording_meeting_control_controller( From 65e1b9ca6c658e81321e176c5c2d63166491d1bd Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Sun, 27 Sep 2026 22:07:26 +0200 Subject: [PATCH 2/3] fix(meetings): retain native pause boundary on cleanup failure --- src/web_api.py | 7 ++++--- tests/test_meeting_api.py | 25 ++++++++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/web_api.py b/src/web_api.py index fb5bbb69..0c039782 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -16776,9 +16776,10 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome: ) ) - # Freeze a successful Stop before recorder/provider draining can - # delay persistence of the state transition. - capture_ended_at = datetime.now(UTC) if command == "audioMeetingStop" else None + # Both Stop and Pause stop native audio. Retain that boundary if + # subsequent recorder/provider cleanup turns Pause into a failure; + # an ordinary paused transition does not persist an end timestamp. + capture_ended_at = datetime.now(UTC) native_payload = response.get("payload") if isinstance(response.get("payload"), dict) else {} native_stop = _meeting_native_stop_snapshot(native_payload) if native_stop: diff --git a/tests/test_meeting_api.py b/tests/test_meeting_api.py index 47cd9304..7b3a7f07 100644 --- a/tests/test_meeting_api.py +++ b/tests/test_meeting_api.py @@ -2249,7 +2249,11 @@ async def release_audio(changed_controller, released_claim): @pytest.mark.asyncio -async def test_stop_keeps_native_capture_end_through_delayed_provider_cleanup(monkeypatch, tmp_path): +@pytest.mark.parametrize("action", ["pause", "stop"]) +@pytest.mark.parametrize("recorder_fails", [False, True]) +async def test_stop_keeps_native_capture_end_through_delayed_provider_cleanup( + monkeypatch, tmp_path, action, recorder_fails +): controller, store, meeting, recorder = _recording_meeting_control_controller( monkeypatch, tmp_path, "stop-capture-end.db" ) @@ -2257,6 +2261,15 @@ async def test_stop_keeps_native_capture_end_through_delayed_provider_cleanup(mo native_end = datetime(2026, 9, 25, 11, 23, 38, tzinfo=UTC) clock = native_end + if recorder_fails: + + def failed_recorder_stop(**_kwargs): + recorder.stop_count += 1 + raise RuntimeError("Meeting audio reader did not stop before the timeout.") + + recorder.stop = failed_recorder_stop + recorder.snapshot = lambda: {"microphone": {"chunks": 1, "errorCode": "reader_stop_timeout"}} + class Clock: @staticmethod def now(_tz): @@ -2281,13 +2294,15 @@ async def release_audio(changed_controller, _claim): ) monkeypatch.setattr(web_api, "_release_persistent_audio", release_audio) app = web_api.create_app(controller) - handler = _route_handler(app, "POST", "/api/meetings/{id}/stop") + handler = _route_handler(app, "POST", f"/api/meetings/{{id}}/{action}") try: response = await handler(_DirectRequest(app, meeting_id=meeting["id"])) - assert response.status == 202 + assert response.status == (503 if recorder_fails else 202 if action == "stop" else 200) result = json.loads(response.body) - assert result["state"] == "finalizing" - assert result["endedAt"] == "2026-09-25T11:23:38Z" + if recorder_fails: + result = result["meeting"] + assert result["state"] == ("capture_failed" if recorder_fails else "finalizing" if action == "stop" else "paused") + assert result["endedAt"] == ("2026-09-25T11:23:38Z" if action == "stop" or recorder_fails else None) assert result["updatedAt"] == "2026-09-27T18:58:09Z" assert store.get(meeting["id"])["endedAt"] == result["endedAt"] finally: From 57c43e36bb6ce283cec76e39377b5eca88e4fc1b Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Sun, 27 Sep 2026 22:07:51 +0200 Subject: [PATCH 3/3] style: format meeting capture boundary regression --- tests/test_meeting_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_meeting_api.py b/tests/test_meeting_api.py index 7b3a7f07..b139162d 100644 --- a/tests/test_meeting_api.py +++ b/tests/test_meeting_api.py @@ -2301,7 +2301,9 @@ async def release_audio(changed_controller, _claim): result = json.loads(response.body) if recorder_fails: result = result["meeting"] - assert result["state"] == ("capture_failed" if recorder_fails else "finalizing" if action == "stop" else "paused") + assert result["state"] == ( + "capture_failed" if recorder_fails else "finalizing" if action == "stop" else "paused" + ) assert result["endedAt"] == ("2026-09-25T11:23:38Z" if action == "stop" or recorder_fails else None) assert result["updatedAt"] == "2026-09-27T18:58:09Z" assert store.get(meeting["id"])["endedAt"] == result["endedAt"]