Skip to content

Fix stale expiry alerts after CGM deletion, retune stuck-glucose detector - #13

Open
ps2 wants to merge 3 commits into
mainfrom
fix/stale-alerts-and-stuck-detector
Open

Fix stale expiry alerts after CGM deletion, retune stuck-glucose detector#13
ps2 wants to merge 3 commits into
mainfrom
fix/stale-alerts-and-stuck-detector

Conversation

@ps2

@ps2 ps2 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Two independent fixes from field reports. Separate commits; happy to split into two PRs if preferred.

1. Retract standing alerts when the CGM is deleted (b2ecddf)

A user removed the Libre CGM after their sensor expired and switched back to a G7, then kept getting "sensor about to expire" alerts a week later — once per app launch.

Nothing retracted the alerts on the deletion path:

  • retractExpiryAlerts was reachable only from discardSensor, which only Replace Sensor calls.
  • The Delete CGM button went straight to notifyDelegateOfDeletion, skipping LibreLoopCGMManager.delete entirely.
  • Loop's AlertStore keeps alerts for the whole local-cache window (90 days in this build), and launch-time playback rebuilds any past-due .delayed alert as .immediate and presents it again (StoredAlert.swift:158-166, AlertManager.swift:515). So the three scheduled expiry alerts re-fired on every launch until acknowledged.

delete now retracts and ends with notifyDelegateOfDeletion instead of a bare completion(). That was wrong in the other direction too — overriding delete without re-issuing the notification meant the debug-menu delete tore down BLE but never actually removed the manager from Loop.

Retraction covers every alert the manager can issue, not just the expiry set: playback replays anything left unacknowledged and unretracted, so a standing sensorAttention or reconnectNeedsReScan notice produces the same symptom. They're listed in one place (allAlertIdentifiers) so a future alert can't be missed. discardSensor retracts the same set and clears the re-scan / sensor-attention state, since those notices belong to the sensor going away.

Retraction is issued before the delegate notification and captures the delegate strongly, so it still lands once Loop releases the manager. retractAlert needs only the identifier, and both unschedules the pending user notification and records the retraction — closing the launch-replay path and the rescheduleMutedAlerts path together.

Workaround for anyone already affected: tap OK on the alert rather than dismissing it. That records acknowledgement and drops it from playback permanently.

2. Retune the stuck-glucose detector (b415f2e)

The detector fired on any current-glucose word repeated across 3 advancing frames — ordinary flat glucose at 1 mg/dL resolution. A healthy 5-hour field capture produced 18 hits at 167, 165, 164, 131, 130 and 91, every one with the surrounding frame bytes and the historic series advancing normally. At that noise level it could never single out the hold it exists to catch, which pinned one value for the better part of an hour.

  • Threshold raised to 12 advancing frames (clears the longest run observed in that capture), then repeats only every 5th frame, so an hour-long hold costs ~10 lines instead of ~55.
  • Every report now carries the sensor's committed 5-min historic series as a second opinion — value, lifeCount, lag, drift since the run opened, and gap from the pinned value. That series is produced independently and lands ~15 min behind, so once a run outlasts its own lag the two records describe the same minutes and should agree. When they don't, the live value is the suspect: STUCK-LATCH. When they do agree — which is what the false-low field report looked like — that's itself the finding, and points at the sensor rather than at our decode.
  • Reports the step that ends a hold (flat glucose resumes by a point or two; a released hold jumps).
  • Stops counting runs of error words, a different failure already surfaced by the quality-assessment path and never forwarded to Loop.

Decision logic moved into a pure StuckGlucoseDetector so it can be tested directly.

3. Make LibreLoopTests runnable (58a63ce)

The test target was wired up correctly but unreachable: the project had no shared scheme, so its schemes were per-user and invisible at workspace level, and building the project standalone can't resolve LibreCRKit / LoopKit / LoopAlgorithm — those come from the workspace.

Adds a shared LibreLoop.xcscheme with build and test actions, following G7SensorKit's layout:

xcodebuild -workspace LoopWorkspace.xcworkspace -scheme LibreLoop \
  -destination 'platform=iOS Simulator,name=iPhone 17' test

Testing

  • Workspace builds clean (Loop scheme, iPhone 17 simulator).
  • 15 tests pass via the new scheme — 4 pre-existing, plus 7 StuckGlucoseDetectorTests and 4 LibreLoopAlertRetractionTests added here.
  • LibreLoopAlertRetractionTests asserts both exit paths (delete and discardSensor) retract every identifier in allAlertIdentifiers, that delete still notifies the delegate, and that the identifier list stays complete. Verified against the pre-fix delete: it fails on both the missing retractions and the missing delegate notification.
  • Replaying all 293 realtime frames from the field capture through the new detector emits 0 lines, down from 18.

Still not runtime-verified end to end: confirming the alert fix on device needs a paired sensor, deleting the CGM, and checking that no alert appears on subsequent launches.

🤖 Generated with Claude Code

https://claude.ai/code/session_0111hASHbYQcnPZSUrafNLaA

ps2 added 3 commits September 8, 2026 01:08
The detector fired on any current-glucose word repeated across 3 advancing
frames, which is ordinary flat glucose at 1 mg/dL resolution. A healthy
5-hour field capture produced 18 hits at 167, 165, 164, 131, 130 and 91,
every one with the surrounding frame bytes and the sensor's historic series
advancing normally. At that noise level it could never single out the hold
it exists to catch, which pinned one value for the better part of an hour.

Raise the threshold to 12 advancing frames, clearing the longest run
observed in that capture, and repeat only every 5th frame afterwards so an
hour-long hold costs ~10 lines instead of ~55.

Run length alone still can't separate a hold from flat glucose, so every
report now carries the sensor's committed 5-min historic series as a second
opinion: its value, lifeCount, lag, drift since the run opened, and its gap
from the pinned value. That series is produced independently and lands ~15
minutes behind, so once a run outlasts its own lag the two records describe
the same minutes and should agree. When they don't, the live value is the
suspect and the line is labelled STUCK-LATCH. When they do agree — which is
what the false-low field report looked like — that is itself the finding,
and points at the sensor rather than at our decode.

Also report the step that ends a hold (flat glucose resumes by a point or
two, a released hold jumps), and stop counting runs of error words, which
are a different failure already surfaced by the quality-assessment path and
never forwarded to Loop.

Decision logic moves into a pure StuckGlucoseDetector so it can be tested
directly. Replaying all 293 realtime frames from the field capture through
it now emits nothing, down from 18 lines.
A user removed the Libre CGM after their sensor expired and switched back to
a G7, then kept getting "sensor about to expire" alerts a week later, once
per app launch.

Nothing retracted the alerts on the deletion path. retractExpiryAlerts was
reachable only from discardSensor, which only Replace Sensor calls; the
Delete CGM button went straight to notifyDelegateOfDeletion and skipped the
manager's delete entirely. Loop's AlertStore keeps alerts for the whole
local-cache window (90 days in this build), and launch-time playback rebuilds
any past-due delayed alert as .immediate and presents it again — so the three
scheduled expiry alerts re-fired on every launch until acknowledged.

delete now retracts and ends with notifyDelegateOfDeletion instead of a bare
completion. That was wrong in the other direction too: overriding delete
without re-issuing the notification meant the debug-menu delete tore down BLE
but never actually removed the manager from Loop.

Retraction covers every alert the manager can issue, not just the expiry set.
Playback replays anything left unacknowledged and unretracted, so a standing
sensorAttention or reconnectNeedsReScan notice produces the same symptom.
They are listed in one place, allAlertIdentifiers, so a future alert can't be
missed. discardSensor retracts the same set and clears the re-scan and
sensor-attention state, since those notices belong to the sensor going away.

Retraction is issued before the delegate notification and captures the
delegate strongly, so it still lands once Loop releases the manager.
retractAlert needs only the identifier, and both unschedules the pending user
notification and records the retraction — which closes the launch-replay path
and the rescheduleMutedAlerts path together.
The test target was wired up correctly but unreachable. The project had no
shared scheme, so its schemes were per-user and invisible at workspace level,
and building the project standalone can't resolve LibreCRKit, LoopKit or
LoopAlgorithm — those come from the workspace. Tests could only be verified
by extracting the type under test and running it as a script.

Add a shared LibreLoop.xcscheme carrying build and test actions, following
G7SensorKit's layout, so the tests run through the workspace:

  xcodebuild -workspace LoopWorkspace.xcworkspace -scheme LibreLoop \
    -destination 'platform=iOS Simulator,name=iPhone 17' test

With that in place, cover the deletion bug that prompted this: a stale
sensor-expiry alert re-firing on every launch for a week after the CGM was
removed. LibreLoopAlertRetractionTests asserts that both exit paths — delete
and discardSensor — retract every identifier in allAlertIdentifiers, that
delete still notifies the delegate, and that the identifier list stays
complete. Verified against the pre-fix delete: it fails on both the missing
retractions and the missing delegate notification.

15 tests pass.
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