Skip to content

fix(app): surface Limitless flash-drain stalls instead of silent success - #12268

Merged
kodjima33 merged 6 commits into
BasedHardware:mainfrom
formed2forge:fix/limitless-sync-while-recording
Aug 27, 2026
Merged

fix(app): surface Limitless flash-drain stalls instead of silent success#12268
kodjima33 merged 6 commits into
BasedHardware:mainfrom
formed2forge:fix/limitless-sync-while-recording

Conversation

@formed2forge

@formed2forge formed2forge commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

The Limitless pendant's protocol makes flash-page drain and recording mutually exclusive (mode command 8) — there is no mode that serves stored pages while a recording session is open. When a drain stalls because the pendant is recording, or because it's full and stuck in recording mode, the 30s stall detector in FlashPageWalSyncImpl ends the pass, WalSyncs.syncAll discards the result, and SyncProvider fell through to toCompleted() — the user saw "synced" while nothing transferred and nothing told them why.

This PR closes the silent-ops half of that failure:

  • FlashPageWalSyncImpl re-queries device status on a stall, still in batch mode, and classifies it via FlashSyncStallReason: recordingSuspected (newest_flash_page advanced past the enumerated end while the drain starved — the pendant is minting pages it won't serve) or deviceFull (flash storage full, pendant halts recording but stays armed and serves no pages until the button stops it). Exposed through WalSyncs.flashStallReason and stamped on the flash_page_download_partial event; the read that decides the reason is now logged via flash_page_stall_classified since it hadn't been observed under a real full-flash condition yet.
  • SyncProvider maps a classified stall with no new conversations to a user-facing error state instead of silent completion — l10n'd across all 49 locales (pendantRecordingSyncBlocked, pendantFullSyncBlocked).
  • The sync error banner (sync_page.dart) was clamped to 2 lines + ellipsis, truncating the new (longer) recovery messages mid-word, worst at larger accessibility text scales. Extracted into a small, tested SyncErrorCard widget that reflows the full message instead of clamping it.
  • Unrelated stalls (plain transfer lulls) keep the existing resume-on-next-sync behavior.

The protocol limitation itself isn't fixable app-side; this only ensures the user learns why sync stopped and how to unblock it (stop recording via the hardware button, then sync again).

Not covered by this PR: while an upload (as opposed to a BLE drain) is in progress, SyncProvider.isSyncing hides the "Sync Now" button entirely, so there's no UI pathway to start a new drain if the pendant's flash fills up mid-upload. That's a distinct UX gap needing its own design (separate "offload device" action vs. running drain/upload concurrently) — filed as #12265.

Branch history note

This branch was rebased onto current upstream/main (it had drifted ~8300 commits behind). Five commits unrelated to Limitless sync — an abandoned side-thread on iOS community-build signing config (dynamic DEVELOPMENT_TEAM/APP_GROUP_IDENTIFIER, dev-entitlement stripping) — were dropped during the rebase rather than carried forward: that work already has its own more current, still-open effort (#7641 / fix/ios-community-build), and mixing it into this PR would just reintroduce a stale, superseded version of it. The generated app_localizations*.dart l10n files were regenerated with flutter gen-l10n after the .arb sources were hand-merged (verified as pure additions matching the .arb diff — see the chore(app): regenerate l10n output commit).

Test plan

  • bash app/test.sh — full suite, see CI/PR checks for pass/fail
  • dart format --line-length 120 on regenerated l10n output — no changes (gen-l10n already formats to the repo's line length)
  • Verified .arb merges are valid JSON and the regenerated .dart getters are pure additions matching the .arb diff
  • Live pendant-in-hand verification of the deviceFull stall path is still pending upstream (per the original commit's notes: only reproduced via injected status in a test harness so far, not yet observed on real hardware with a genuinely full pendant)

Failure-Class: new

New class added in this PR: FC-drain-stall-completes-silently (.github/failure-classes/FC-drain-stall-completes-silently.json) — a device-storage drain stall caused by a structurally-unservable state (protocol mode conflict, full storage still armed for recording) must be classified and surfaced as an actionable error, never allowed to fall through to a generic "completed, nothing new" success.

🤖 Generated with Claude Code

Review in cubic

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @formed2forge — this closes a genuinely nasty silent-failure mode, and the implementation is careful. Detailed pass below.

What I verified

  • flash_page_wal_sync.dart: classifyStall() is well-grounded — free_capture_pages is a real field the DeviceStatus parser populates (marker 0x20 in _parseStorageStateFromDeviceStatus), so the deviceFull branch reads actual firmware state rather than an assumed key. Precedence is right (deviceFull checked before the recording heuristic, with a test pinning "full takes precedence over newest-page movement"), and the false-positive guards point the safe way: a missing/partial status read and a newest page behind the enumerated end (pages ACKed away) both classify as unknown, which preserves today's behavior.
  • Reset semantics are clean: _lastStallReason is reset to none at the top of both syncAll() and syncWal(), so a stale classification can't leak into the next pass. Classifying while still in batch mode, before enableRealTimeMode(), is the right window.
  • sync_provider.dart: the two new branches in _performSync sit before the toCompleted() fallback and only fire on a classified stall — plain transfer lulls (unknown) keep the existing resume-on-next-sync behavior, pinned by the provider test. The English fallback for headless runs is pragmatic.
  • sync_error_card.dart + sync_page.dart: extracting the banner and dropping maxLines: 2/ellipsis is a real accessibility fix, not churn — the recovery instruction is exactly what used to get cut, and the widget test asserts both no-clamp and no-overflow at 2.0 text scale.
  • l10n: both new keys (pendantRecordingSyncBlocked, pendantFullSyncBlocked) are present in all 49 locales; the .arb diffs are purely additive (plus the trailing-comma fix on prerecordedTranscript).
  • .github/failure-classes/FC-drain-stall-completes-silently.json matches the schema of the existing failure-class entries.

Checks are green on this head (Dart Analyze and Tests, Android Compile Smoke, Generated Files, Formatting, Hygiene; the skipped jobs are out-of-scope skips). No split needed — it's one outcome (surface flash-drain stalls) and the l10n churn is intrinsic to it.

Minor observations (non-blocking)

  1. In _performSync, _hasConversationResults(result) takes precedence over the stall branches. In a combined pass — pending phone-file uploads that produce conversations and a stalled flash drain — the user still sees "completed" and the stall is silent again. No data is lost (the flash WAL stays miss and the next sync resumes), but it's a narrow residual path worth a follow-up, e.g. a partial-success-plus-stall state.
  2. The new error messages are resolved to localized prose at state-set time (_pendantRecordingMessage() via globalNavigatorKey), while the newer convention in this file (pendingUploadErrorCode) keeps provider state machine-readable and lets surfaces own localization. It follows the ai_app_generator_provider precedent, so fine — just noting the two patterns now coexist.
  3. The deviceFull semantics rest on firmware behavior under a real full-flash condition that (per the PR notes) hasn't been observed live yet. The defensive design (misclassification degrades to unknown → current behavior) plus the new flash_page_stall_classified telemetry is the right mitigation — worth watching that event after release.

Leaving the merge call to a maintainer; the one hardware-dependent assumption behind deviceFull is the only thing that can't be verified from code.


by AI on behalf of David — happy to re-review after changes; ping @Git-on-my-level with need human response if a human ruling is required.

@Git-on-my-level Git-on-my-level added positive-signal Good PR — positive signal, not a formal approval flutter flutter work labels Aug 27, 2026
@formed2forge

Copy link
Copy Markdown
Contributor Author

@mdmohsin7 — ping for review. Code review by Git-on-my-level is complete and positive, CI is green. Just waiting on your sign-off.

formed2forge and others added 6 commits August 27, 2026 11:11
…en Limitless flash sync stalls

Root cause: the Limitless protocol's mode command (msg 8) makes flash-page
drain and recording mutually exclusive — there is no mode that serves stored
pages while a recording session is being written. When the pendant is
hardware-button recording, the drain starves, the 30s stall detector in
FlashPageWalSyncImpl ends the pass, WalSyncs.syncAll discards the result, and
SyncProvider falls through to toCompleted() — the user sees "synced" while
nothing transferred and nothing tells them to stop recording.

Durable guard:
- On a stall, FlashPageWalSyncImpl re-queries device status while still in
  batch mode and classifies the stall: newest_flash_page advanced past the
  enumerated end while the drain starved => recordingSuspected (the pendant
  is minting pages it will not serve). Exposed as FlashSyncStallReason via
  WalSyncs.flashStallReason; stamped on the flash_page_download_partial event.
- SyncProvider maps a recordingSuspected stall with no new conversations to a
  user-facing error state ("Press the Pendant's button to stop recording,
  then sync again") instead of silent completion. Message is l10n'd across
  all 49 locales (pendantRecordingSyncBlocked).
- Unknown stalls (plain transfer lulls) keep the existing resume-on-next-sync
  behavior; WAL stays 'miss' with an advanced storageOffset either way.

The protocol limitation itself is not fixable app-side; this closes the
silent-ops half of the failure (the user now learns why sync stopped and how
to unblock it). The native Transcribe Later drain engines share the silent
stall pattern (NSLog-only) — deferred as a separate surface.

Verification:
- flutter test test/unit/flash_page_stall_classification_test.dart
  test/providers/sync_provider_flash_stall_test.dart — 7/7 pass (regression
  test asserts the stall no longer reports success).
- bash app/test.sh — 753/753 pass.
- flutter gen-l10n — zero untranslated messages.
- bash app/scripts/analyze_ratchet.sh — passed.
- Live pendant-in-hand verification pending (pendant currently paired to the
  TestFlight build); code path exercised via provider-level tests through the
  real _performSync flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ash sync stalls

A full Limitless pendant halts recording (red LED flash) but stays armed in
recording mode, and in that state the firmware serves no flash pages: an
offline sync starves, the 30s stall detector ends the drain, and the result
used to fall through to `toCompleted` — telling the user everything synced
when nothing did. This is the real-world trigger behind the silent-stall bug
(confirmed on hardware); the existing `recordingSuspected` path can never fire
for it, because a full pendant cannot mint new flash pages, so the
newest-page-advanced heuristic stays false.

Root cause / durable guard: classify a stall with zero `free_capture_pages` as
a new `FlashSyncStallReason.deviceFull` (checked before the recording
heuristic, since fullness cannot be inferred from page movement). SyncProvider
gains a matching error branch and a full-specific message telling the user to
press the button to stop recording, then sync again — the exact recovery the
firmware requires.

l10n: new key `pendantFullSyncBlocked` translated across all 49 locales;
`flutter gen-l10n` reports zero untranslated.

Tests: extended classifyStall unit tests (zero-free = deviceFull, full takes
precedence over newest-page movement, free-remaining stays unknown) and the
SyncProvider regression test (deviceFull surfaces an error, not success).

Verification:
- `flutter test` on the stall + provider suites: 11 passed.
- `scripts/analyze_ratchet.sh`: passed. `flutter gen-l10n`: 0 untranslated.
- Hardware (iPhone 17 Pro, dev build): with a deterministic test harness that
  drove the drain into a stall and injected free_capture_pages=0, the classifier
  logged `deviceFull` and the full-storage message rendered on screen. Real
  full-pendant repro (passive, ~24h to refill flash) still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync error banner clamped its message to `maxLines: 2` + ellipsis, so a
long recovery message was cut mid-word ("…storage is full and i…"), hiding the
very instruction the user needs to act on. It was worst at larger iOS
accessibility text scales, where two lines hold even less. Found while
verifying the pendant-full error on a device with enlarged system fonts.

Extract the banner into a small `SyncErrorCard` widget (reviewable, testable)
that drops the line clamp so the message reflows in full, and top-aligns the
Row so the icon and Retry pill stay put when the text wraps to several lines.
Behavior-preserving for the common short-error case.

Tests: `sync_error_card_test.dart` asserts the message is never clamped
(maxLines null, no ellipsis) and that the full message stays visible without a
layout overflow at a 2x accessibility text scale — the regression that would
have caught the original bug.

Verification:
- `flutter test test/widgets/sync_error_card_test.dart`: 2 passed.
- `scripts/analyze_ratchet.sh`: passed (prefer_const_constructors improved by 1).
- Hardware (iPhone 17 Pro, dev build, enlarged accessibility fonts): the full
  "Your Pendant's storage is full…press the Pendant's button…then sync again"
  message renders across multiple lines with no truncation (screenshot before
  and after the extraction confirm identical, full-message rendering).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persist a `flash_page_stall_classified` event (reason + whether the post-stall
status read was null + free/newest page counters) at the point the drain stall
is classified. This is the one read that decides which message the user sees,
and it was previously unlogged.

Rationale: the deviceFull trigger is confirmed only through injected status in
a test harness — a real full pendant reporting `free_capture_pages <= 0` in a
clean status read has not yet been observed (the pendant sat ~65% full all
session, and one real status read during a stall came back malformed with no
free-page field at all). If the next natural full event classifies as
`unknown` and silently completes, this record is the difference between
"assumption was wrong (full != free==0)" and "the status read failed" — turning
the passive full-pendant repro into a conclusive result instead of a guess.
Aligns with the repo's "silent ops is not allowed" observability rule.

Verification: analyzer ratchet passes; stall + provider suites still pass (11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto current upstream/main hand-merged the .arb sources for
pendantRecordingSyncBlocked/pendantFullSyncBlocked (added new keys at the
tail of each of the 49 locale files, colliding with hundreds of upstream
insertions at the same position) but left the generated
app_localizations*.dart getters stale, since regenerating those
correctly requires the toolchain rather than a text merge.

flutter gen-l10n from the merged .arb sources.
Declares the failure-class boundary these Limitless flash-drain fixes repair:
a device-storage drain stall caused by a structurally-unservable device state
(protocol mode conflict, full storage still armed for recording) must be
classified and surfaced as an actionable error, never silently fall through
to a generic "completed, nothing new" success. Two fixes in this PR
(recordingSuspected, deviceFull) share this cause and its guard
(FlashSyncStallReason + SyncProvider's classified-stall branch), so this
records the reusable boundary rather than treating each as an isolated bug.
@formed2forge
formed2forge force-pushed the fix/limitless-sync-while-recording branch from e391323 to eb89db2 Compare August 27, 2026 14:12
@formed2forge

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main — one merge conflict in sync_page.dart's _buildSyncErrorCard resolved by keeping the PR's SyncErrorCard widget refactor (upstream had added an Expanded wrapper inline; the PR's widget extraction supersedes that). All 6 commits land cleanly. CI should re-run.

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified the FlashSyncStallReason/deviceFull classification is not yet on main (bug is real). CI green, scoped code change (bulk of diff is auto-generated l10n across 49 locales). Confidence 4/5 bug fix — merging.

@kodjima33
kodjima33 merged commit 01623c7 into BasedHardware:main Aug 27, 2026
26 checks passed
@cursor
cursor Bot deleted the fix/limitless-sync-while-recording branch September 2, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

flutter flutter work positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants