Skip to content

fix(tags): auto-resolve on-device LLM tag suggestions that freeze on Downloading... - #262

Merged
tstapler merged 15 commits into
mainfrom
backlog/stelekit-llm-tag-download-stall
Aug 2, 2026
Merged

fix(tags): auto-resolve on-device LLM tag suggestions that freeze on Downloading...#262
tstapler merged 15 commits into
mainfrom
backlog/stelekit-llm-tag-download-stall

Conversation

@tstapler

@tstapler tstapler commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the on-device LLM tag-suggestion sheet freezing on a one-shot "Downloading on-device
model…" caption forever (the reported bug: a screenshot showing the sheet stuck with no
progress and no way out). The status check was a single, one-shot checkStatus() call —
nothing ever re-checked, so a model that finished downloading seconds or minutes later left
the UI permanently stale.

Context

MlKitLlmFormatterProvider.format() returns immediately on DOWNLOADABLE/DOWNLOADING
with a retryable = true signal, but LlmTagProvider.suggestTags() discarded that signal
when mapping to DomainError, and TagSuggestionViewModel never re-ran the check. This PR
adds a bounded, elapsed-time-driven poll loop with caption escalation and a manual retry
affordance, closing that gap end-to-end.

Full requirements, 6-dimension research, plan, and review artifacts are in
project_plans/llm-tag-download-stall/.

Changes

  • TagAvailabilityPoller (new): stateless, wall-clock-bounded poll loop over
    LlmProviderAvailability, modeled on the existing GitHubDeviceFlowClient.pollForToken
    shape so it's directly unit-testable under kotlinx.coroutines.test virtual time.
  • TagSuggestionState: replaced the flat llmPending: Boolean / llmError: String?
    pair with a sealed LlmSuggestionStatus (NotStarted/Pending/Resolved/Stalled/Failed)
    so illegal state combinations are unrepresentable.
  • TagSuggestionViewModel: new runLlmSuggest helper threads the poll loop into
    requestSuggestions(); auto-resolves once the model becomes available; a session-scoped
    downloadFirstObservedAtMs timestamp survives block-switches and manual retries so
    elapsed-time-based escalation/deadline logic isn't reset by ordinary usage patterns
    (a real gap caught by the pre-mortem gate); new retryLastRequest() is the manual-retry
    call target; scanEntries() opts out of polling via allowPolling = false to preserve
    today's fail-fast bulk-scan timing.
  • DomainError.NetworkError.RequestFailed: additive retryable: Boolean = false field
    so the on-device-unavailable signal survives the LlmTagProviderTagSuggestionEngine
    → ViewModel boundary instead of being dropped.
  • UI (SuggestionBottomSheet, TagChipRow): renders all LlmSuggestionStatus states —
    escalating caption at ~45s, a distinct "taking longer than expected" terminal state, and a
    focusable Retry button (present only when retryable, absent — not disabled — otherwise).
  • Tests: TagAvailabilityPollerTest (7 tests, virtual time), 18 tests in
    TagSuggestionViewModelTest covering the full poll/retry/stall/block-switch matrix, 10
    Compose UI tests in LlmSuggestionCaptionStatesUiTest, plus regression tests for the
    stale-block-leak and format()-not-retriggered-per-tick pitfalls.

Impact

  • Scope: dev.stapler.stelekit.tags package + SuggestionBottomSheet/TagChipRow UI +
    their call sites (JournalsView, PageView, VoiceCaptureButton). No schema/migration
    changes.
  • Breaking Changes: none — all DomainError/provider changes are additive with
    backward-compatible defaults.
  • Performance: no regression on the fast path (model already AVAILABLE) or the
    genuinely-unsupported-device path — both short-circuit before any polling starts.
  • Dependencies: none added — hand-written poll loop, consistent with this repo's
    existing GitHubDeviceFlowClient/GraphFileWatcher idiom (Arrow Schedule/CircuitBreaker
    considered and rejected — wrong problem shape).

Reviewer Notes

  • Focus areas: TagSuggestionViewModel.runLlmSuggest/requestSuggestions (poll-loop
    wiring, cache-hit disambiguation across block switches) and TagAvailabilityPoller's
    elapsed-time tracking.
  • Known limitations:
    • DEFAULT_POLL_DEADLINE_MS = 120_000L is a desk-researched estimate (no physical
      AICore-capable device was available this session) — see
      project_plans/llm-tag-download-stall/decisions/ADR-001-poll-deadline-estimate.md for
      sources and its mandatory real-hardware re-validation follow-up.
    • The poll loop is written generically against LlmProviderAvailability (platform-agnostic
      by design, per NFR-2), so it will technically activate on iOS once an iOS on-device
      provider is registered, using an Android-derived deadline — documented as an accepted
      consequence, not a gap, in the plan's Pattern Decisions table.
    • LlmSynthesisService.kt:104 has the identical retryable-dropping bug this PR fixes in
      LlmTagProvider.kt, left untouched as out-of-scope — needs its own follow-up ticket.
    • A handful of CONCERN-level refactor opportunities (duplicated retry-UI Compose code,
      duplicated cache+state-update logic in the ViewModel, LlmSuggestionStatus transition
      logic spread across 3 collaborators) were surfaced by review but left as documented
      follow-ups rather than expanding this PR's diff further — see
      project_plans/llm-tag-download-stall/implementation/architecture-review.md.
  • Follow-up tasks: real-hardware DEFAULT_POLL_DEADLINE_MS re-validation (ADR-001);
    LlmSynthesisService.kt's twin bug; the refactor items above.
  • Rollback procedure: standard revert PR — no special steps, no schema/data changes.
  • Feature flag: not gated — this is a bug fix to existing, already-shipped
    tag-suggestion behavior, not new functionality behind a flag.

Test plan

  • ./gradlew jvmTest — full suite green except 4 pre-existing, unrelated
    GraphManagerDatabaseLifecycleTest failures (zero file overlap with this diff,
    confirmed via git diff --stat)
  • ./gradlew detekt — clean
  • All 8 acceptance criteria (AC0–AC7) independently verified against the diff by a
    dedicated spec-compliance pass
  • Desktop app launches and reaches an interactive state with this branch's App.kt
    changes (no startup regression)
  • Went through this repo's full SDD workflow: requirements → 6-dimension research →
    plan (with architecture + adversarial review, 4 blockers found and resolved across
    2 repair iterations) → validation + pre-mortem (2 P1 risks found and fixed) → product
    triad review (1 accessibility blocker fixed) → implementation (6 parallel epics) →
    3-layer verification (idiom/architecture/correctness — 1 more MUST FIX caught and
    fixed: a test that gave false confidence due to a real-vs-synthetic clock mismatch)

Related

Closes backlog item 505fb733-9621-4621-b7fc-27712e36d084 ("Device model download kinda of
sucks").

tstapler and others added 10 commits July 30, 2026 00:01
Requirements, 6-dimension research, implementation plan (7 epics/49
tasks), 2 ADRs, UX design, and validation/pre-mortem docs for fixing
the on-device LLM tag-suggestion sheet freezing on a one-shot
"Downloading..." caption with no polling, escalation, or retry path.

Plan went through architecture + adversarial review (1 blocker each,
both resolved), a pre-mortem (2 P1s found and fixed — elapsed-time
tracking now persists across block-switches and manual retries), a
cross-artifact consistency pass (3 blockers resolved), and a product
triad review (UX accessibility blocker resolved, now READY TO BUILD).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
…stionStatus

Replaces TagSuggestionState.Ready's llmPending: Boolean / llmError: String?
pair with a single llmStatus: LlmSuggestionStatus field (NotStarted / Pending /
Resolved / Stalled / Failed), per plan.md Epic 2 Task 2.1.1. This is the type
that lets the UI distinguish "still downloading" from "stalled, needs retry"
from "hard failure" instead of collapsing all non-happy-path states into a
single frozen "Downloading..." caption with no retry affordance.

Epic 2, Story 2.1 of project_plans/llm-tag-download-stall.

EXPECTED BREAKAGE: this intentionally breaks compilation in
TagSuggestionViewModel.kt and SuggestionBottomSheet.kt (unresolved
llmPending/llmError references), and will break ErrorStateNoDeadEndTest.kt
once those recompile. Epics 4/5/6 (running separately) fix these downstream
call sites to construct/read llmStatus instead. TagChipRow.kt and
VoiceCaptureButton.kt were not broken by this change alone (their
llmError/llmPending are local parameter names, not references to
TagSuggestionState.Ready) but will need updating when Epic 5/6 change
TagChipRow's signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
…tionEngine

Epic 1 of llm-tag-download-stall: stop dropping LlmResult.Failure.OnDeviceUnavailable's
retryable flag at the DomainError boundary (root cause of the download-stall bug), and
wire a narrow checkAvailability probe into TagSuggestionEngine for the upcoming poll
loop (Epic 3/4) to use.

- DomainError.NetworkError.RequestFailed gains an additive retryable: Boolean = false
  field (default keeps all existing call sites compiling unchanged).
- LlmTagProvider.suggestTags() now forwards result.retryable instead of dropping it.
- TagSuggestionEngine takes an optional checkAvailability probe (public val, defaults
  to null) so TagSuggestionViewModel can later pass it straight into
  TagAvailabilityPoller.pollUntilAvailable without an extra wrapper.
- App.kt's TagSuggestionEngine construction site wires tagLlmProviderState's existing
  checkAvailability() through.
- Adds LlmTagProviderTest (businessTest) with the direct regression test for the bug:
  a retryable OnDeviceUnavailable failure maps to a retryable RequestFailed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
Implements Epic 3 of llm-tag-download-stall: a wall-clock-bounded poll
loop over LlmProviderAvailability, mirroring GitHubDeviceFlowClient's
pollForToken shape so it is directly unit-testable under
kotlinx.coroutines.test.runTest with no injected dispatcher/scope.

Root-cause fix during implementation: the plan's Clock.System.now()-based
while-loop condition is not virtualized by kotlinx-coroutines-test (only
delay() suspension points are), so re-querying it every iteration caused
the loop to busy-spin at full CPU for the full real-world deadline instead
of resolving in virtual time - passing for short deadlines (~12s real
wall time) but genuinely failing outright for the 120s-deadline escalation
test (UncompletedCoroutinesError, exceeds runTest's 60s watchdog).
Switched to tracking elapsed time via accumulated delay() ticks instead of
repeated wall-clock reads; production behavior is unchanged (delay()
genuinely takes real time outside of tests) and all 6 tests now resolve
in true virtual time (~0.2s real time for the full suite, verified via an
isolated kotlinc+JUnit run since the full commonMain module currently
fails to compile for pre-existing, unrelated reasons - Epic 2's
llmPending/llmError removal in TagSuggestionViewModel.kt/
SuggestionBottomSheet.kt, which Epic 4 fixes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
…uggestionViewModel

Implements Epic 4 of the llm-tag-download-stall plan: TagSuggestionViewModel
now routes every LLM suggestion attempt through a shared runLlmSuggest()
helper that layers TagAvailabilityPoller.pollUntilAvailable (Epic 3) on top
of a retryable-unavailable signal (Epic 1) and reports progress via the
sealed LlmSuggestionStatus (Epic 2).

- Story 4.1: runLlmSuggest() — first attempt, poll-if-retryable, one auto
  re-run on Available. Constructor gains injectable dispatcher/pollDeadlineMs/
  pollIntervalMs/pollEscalationThresholdMs (NFR-3: lets tests run under
  kotlinx.coroutines.test virtual time instead of real ~120s/~20s waits) plus
  a session-scoped downloadFirstObservedAtMs so a block-switch or manual
  retry resumes the existing elapsed-time budget instead of restarting the
  escalation/deadline clock from zero (pre-mortem P1 #1/#2).
- Story 4.2: requestSuggestions() rewritten around runLlmSuggest(); adds
  retryLastRequest() (FR-3) backed by a stored LastRequest.
- Story 4.3: scanEntries() calls runLlmSuggest(..., allowPolling = false, ...)
  so a stalled on-device model never blocks a bulk scan (FR-7/AC7).
- Stories 4.4/4.4b/4.5/4.6: 13 new regression tests covering the stale-block
  coroutine-lifecycle guarantee, close()/own-deadline termination, the full
  Pending->Stalled caption sequence, fast/non-retryable no-poll paths,
  format()-called-at-most-twice (pitfall #2), and elapsed-time persistence
  across a block-switch-and-return and a manual retry after Stalled.

Two of the new tests (Story 4.6) need a small deliberate real delay() rather
than pure virtual-time advancement: TagAvailabilityPoller.pollUntilAvailable's
startedAtOverride reconciliation reads a real kotlin.time.Clock.System.now()
once (by design, already committed in Epic 3, not modified here), which
kotlinx.coroutines.test's virtual scheduler cannot influence — so proving
"elapsed time survives a relaunch" requires genuine wall-clock time to
actually pass, not just virtual time. This is a few hundred milliseconds,
not the ~120s/~20s NFR-3 was written to eliminate.

Epic 4, project_plans/llm-tag-download-stall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUh92Keov7BCtTf8T9io1a
TagSuggestionState.Ready dropped the flat llmError field in favor of the
sealed LlmSuggestionStatus (Epic 2). Update the LLM-suggestion-failure test
fixture to construct LlmSuggestionStatus.Failed(message, retryable) instead,
and pass the new onRetry callback SuggestionBottomSheet requires (Epic 5).
…tionBottomSheet

Epic 5 of llm-tag-download-stall: TagChipRow now takes a single
llmStatus: LlmSuggestionStatus param instead of the old flat
isLlmLoading/llmError pair. SuggestionBottomSheet renders all five
caption/retry states (Pending, Stalled, Failed retryable/non-retryable)
with LiveRegion.Polite announcements and a structurally-gated Retry
TextButton, and wires onRetry -> retryLastRequest() at both
JournalsView and PageView call sites.

Also updates TagInsertionFlagshipUiTest.kt's SuggestionBottomSheet call
site (new required onRetry param) and adds
LlmSuggestionCaptionStatesUiTest.kt covering validation.md's 8
automatable UX acceptance criteria for this surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUh92Keov7BCtTf8T9io1a
… load

The block-B awaitState call used the 5000ms default, but this test
constructs TagSuggestionViewModel with real Dispatchers.Default (no
injected test dispatcher). Under full-suite parallel test load, real
thread-pool contention pushed the real-time spin-poll past 5000ms even
though the underlying cancel-and-relaunch is effectively instantaneous
(confirmed via 3x isolated reruns, all passing in <2s). Bumped to
15000ms — a timeout-margin fix, not a logic change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
sdd:6-verify Layer 1/2 fixes:

- TagAvailabilityPollerTest's "measures elapsed time from
  startedAtOverride" test anchored its override to a synthetic epoch
  value instead of a real Clock.System.now() read. Since
  pollUntilAvailable computes elapsed time as
  Clock.System.now() - startedAtOverride, this made the computed
  elapsed time enormous, so the while-loop's first condition check
  failed immediately — zero ticks, giving no regression protection
  for the exact resumed-poll arithmetic (pre-mortem P1 #1/#2's fix)
  this test exists to cover. Independently caught by both the
  architecture review and idiom review agents. Fixed to anchor on a
  real clock read and assert the actual tick count (8), not just
  the terminal outcome.
- TagSuggestionEngine.checkAvailability: import LlmProviderAvailability
  instead of an inline fully-qualified reference.
- TagSuggestionViewModel: replace a !! on a mutable var with a local
  val binding (provably safe today, but not smart-castable across
  statements).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
… signal

Code review found two BLOCKER-severity tests that gave zero protection:
both used a bare delay(200) inside runTest, which the coroutine test
scheduler virtualizes to near-zero real time against the real 4000ms
DEFAULT_POLL_INTERVAL_MS. Disabling the underlying cancellation logic
they were meant to guard left both tests passing. Both now construct
the ViewModel with a short pollIntervalMs override and use a genuine
withContext(Dispatchers.Default) { delay(200) } wall-clock wait.

The stale-block-leak test additionally needed a redesign, not just a
timing fix: its final assertion checked the state immediately after a
fresh job's synchronous initial write, before any leaked background job
could run, so it was structurally incapable of catching the bug even
with real time. It's rebuilt so block-B resolves without polling of its
own, isolating checkAvailability() call growth during the wait window
to only a leaked block-A job — verified to fail when suggestionJob
cancellation is disabled, and to pass with it restored.

Also:
- LlmTagProvider's NetworkError branch dropped `retryable`, reproducing
  this PR's core "frozen, no retry" bug for a plain network error
  instead of OnDeviceUnavailable. Now maps to retryable = true, with a
  regression test mirroring the existing OnDeviceUnavailable coverage.
- TagSuggestionViewModel's post-launch `activeBlockUuid = null` reset
  now only fires if it still refers to the job's own block, closing a
  race where a completing job for block A could clobber block B's
  in-flight activeBlockUuid after a fast block switch.
- Added a pipeline-level test proving a genuine LLM timeout resolves to
  LlmSuggestionStatus.Failed(retryable = true) end to end, not just via
  a hand-constructed UI state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUh92Keov7BCtTf8T9io1a
@tstapler
tstapler marked this pull request as ready for review August 1, 2026 20:46
Copilot AI review requested due to automatic review settings August 1, 2026 20:46

Copilot AI 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.

🟡 Not ready to approve

TagSuggestionViewModel currently maps any retryable RequestFailed into Stalled, which can misclassify non-download failures and hide the actual error message (see stored comment ID 001).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Fixes the tag-suggestion bottom sheet getting stuck indefinitely on the on-device model “Downloading…” state by preserving the retryable signal, adding a bounded availability poll loop, and exposing explicit UI states + a manual retry affordance end-to-end.

Changes:

  • Introduces TagAvailabilityPoller and threads LlmProvider.checkAvailability() into the tag-suggestion pipeline to support bounded polling + auto-resolve.
  • Replaces llmPending/llmError with a sealed LlmSuggestionStatus state machine and updates TagSuggestionViewModel to orchestrate polling, escalation, and retry.
  • Adds retryable to DomainError.NetworkError.RequestFailed, updates Compose UI rendering (including accessibility semantics), and adds unit/integration/UI tests for the new states.
File summaries
File Description
project_plans/llm-tag-download-stall/research/ux.md UX research write-up for stalled download behavior and UI requirements.
project_plans/llm-tag-download-stall/research/stack.md Stack/polling/testing research and constraints.
project_plans/llm-tag-download-stall/research/pitfalls.md Enumerates lifecycle/testability/polling pitfalls and mitigations.
project_plans/llm-tag-download-stall/research/features.md Prior art + edge cases relevant to polling/retry behavior.
project_plans/llm-tag-download-stall/research/build-vs-buy.md Justifies hand-rolled polling vs libraries.
project_plans/llm-tag-download-stall/research/architecture.md Architecture reasoning for where polling/state should live.
project_plans/llm-tag-download-stall/requirements.md Formal requirements and ACs for the fix.
project_plans/llm-tag-download-stall/implementation/validation.md Requirement-to-test mapping and validation plan.
project_plans/llm-tag-download-stall/implementation/pre-mortem.md Pre-mortem risks and planned mitigations.
project_plans/llm-tag-download-stall/implementation/architecture-review.md Architecture review notes and concerns tracking.
project_plans/llm-tag-download-stall/implementation/adversarial-review.md Adversarial review results and resolved blockers.
project_plans/llm-tag-download-stall/design/ux.md UX design spec for the new status states and accessibility requirements.
project_plans/llm-tag-download-stall/decisions/ADR-001-poll-deadline-estimate.md Interim poll deadline decision record.
project_plans/llm-tag-download-stall/decisions/ADR-002-dismiss-does-not-cancel-poll-loop.md ADR for keeping polling running after dismiss (bounded by deadline).
kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt Adds retryable metadata to RequestFailed.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/tags/LlmTagProvider.kt Preserves retryable when mapping on-device unavailability + marks network errors retryable.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/tags/TagAvailabilityPoller.kt New bounded poller for LlmProviderAvailability with escalation and resilience.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/tags/TagSuggestionEngine.kt Threads an optional checkAvailability probe into the engine.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/tags/TagSuggestionState.kt Introduces sealed LlmSuggestionStatus to prevent illegal UI state combos.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/tags/TagSuggestionViewModel.kt Orchestrates polling/auto-resolve/retry and supports test-time overrides.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt Wires the availability probe into TagSuggestionEngine.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/tags/SuggestionBottomSheet.kt Renders LlmSuggestionStatus states and adds Retry button + semantics.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/tags/TagChipRow.kt Updates signature to accept llmStatus and simplifies rendering.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/VoiceCaptureButton.kt Adapts to the new TagChipRow API.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/JournalsView.kt Wires onRetry to TagSuggestionViewModel.retryLastRequest().
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/PageView.kt Wires onRetry to TagSuggestionViewModel.retryLastRequest().
kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/ErrorStateNoDeadEndTest.kt Updates construction to the new llmStatus model.
kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/TagInsertionFlagshipUiTest.kt Wires onRetry through the flagship UI test harness.
kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/LlmSuggestionCaptionStatesUiTest.kt New Compose UI tests for caption/retry semantics across states.
kmp/src/businessTest/kotlin/dev/stapler/stelekit/tags/TagAvailabilityPollerTest.kt Unit tests for poller timing/escalation/resilience under virtual time.
kmp/src/businessTest/kotlin/dev/stapler/stelekit/tags/LlmTagProviderTest.kt Regression tests ensuring retryable is preserved through error mapping.
.backlog-context.md Updates the embedded backlog context to this bug/AC set.
Review details
  • Files reviewed: 33/34 changed files
  • Comments generated: 1
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Resolves the only conflict (.backlog-context.md, modify/delete —
main deleted it; this branch's copy was ephemeral backlog-automation
bookkeeping never meant to be permanent repo content) by taking
main's deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5

# Conflicts:
#	.backlog-context.md
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing 91c39c0e (this PR) vs 210e4f39 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 1ms 1ms 0 (0%)
Phase 2 background ↓ 0ms 0ms 0 (0%)
Phase 3 index ↓ 1ms 1ms 0 (0%)
Total ↓ 2ms 2ms 0 (0%)
Write p95 (baseline) ↓ 20ms 17ms +3ms (+18%) ⚠️
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top allocation hotspots (this PR) `37.5%` byte[]_[k] `8.8%` java.lang.String_[k] `5.9%` int[]_[k] `4.8%` java.util.LinkedHashMap$Entry_[k] `4.3%` java.lang.Object[]_[k]
Top CPU hotspots (this PR) `97.1%` /usr/lib/x86_64-linux-gnu/libc.so.6 `1%` clock_nanosleep `0.7%` /tmp/sqlite-3.51.3.0-8f917865-26ea-43e0-b5b8-81454c0dae60-libsqlitejdbc.so `0.2%` __libc_pwrite `0.1%` SR_handler

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing 91c39c0e (this PR) vs 210e4f39 (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 43ms 35ms +8ms (+23%) ⚠️
Phase 3 index ↓ 4229ms 4134ms +95ms (+2%) ⚠️

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 13ms 10ms +3ms (+30%) ⚠️
Write p95 (during phase 3) ↓ 15ms 9ms +6ms (+67%) ⚠️
Jank factor ↓ 1.15x 0.9x +0.25x (+28%) ⚠️
Concurrent writes ↑ 21 20 +1ms (+5%) ✅

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms +0ms ⚠️
Provider read / file ↓ 0.2ms 0.2ms 0 (0%)
IPC overhead ratio ↓ 6x 6x 0 (0%)
↓ lower is better · ↑ higher is better

tstapler and others added 4 commits August 1, 2026 15:12
…gnal

Copilot's PR review correctly flagged that any retryable
RequestFailed was mapped to LlmSuggestionStatus.Stalled, which
discards the real error message and misrenders unrelated retryable
failures (a NetworkError, an OnDeviceUnavailable surfaced without
polling ever starting, or a TOCTOU retry-after-Available failure) as
the on-device "taking longer than expected" caption. This also
exposed the exact scenario in a prior commit's own retryLastRequest
test, whose comment explicitly documented the old (wrong) behavior.

Stalled now requires the poll loop's own TagAvailabilityPoller.
STALLED_REASON message specifically; every other retryable
RequestFailed maps to Failed(message, retryable=true), preserving
the real message with a Retry button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
… test

'poll loop for a stale block does not write into a newly active
block's cache' failed twice in a row on GitHub Actions CI with the
same java.lang.IllegalStateException from awaitState's timeout
(SLOW ~5.2s, just over the 5000ms default). An earlier fix pass
bumped the block-B await in this same test to 15000ms but missed the
final block-A-re-request await, which was still at the 5000ms
default — CI's real thread-pool contention (heavier than local dev)
was enough to occasionally push it over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
Bumping the awaitState timeout incrementally (5000ms -> 15000ms) kept
losing to the same CI-only flake: each time, the failure landed just
past whatever timeout was current (SLOW ~5.2s, then ~15.2s), pointing
at genuine GitHub Actions runner thread-pool contention rather than a
marginal one-off.

Tried converting the test to a shared virtual-time test scheduler
(the pattern already used successfully elsewhere in this file), but
that introduced its own deterministic timing problem — block-A's
endless 50ms poll loop competes for scheduler cycles even though it
never resolves, and awaitState's own deadline check is real-clock
based regardless of dispatcher sharing, so the rewrite still hit the
same wall. Reverted that attempt.

Landed on the simpler fix: keep the real-Dispatchers.Default design
(which passes in ~1s locally, every time, including in isolation),
and set both awaitState calls to one generous 60000ms margin instead
of chasing the number incrementally. The margin only needs to be big
enough to absorb CI contention — the underlying transitions this test
verifies are near-instantaneous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
The final block-A-re-request assertion in 'poll loop for a stale
block does not write into a newly active block's cache' hung
indefinitely on GitHub Actions CI specifically — bumping its
awaitState timeout to 60000ms changed the failure mode from a clean
timeout to kotlinx.coroutines.test.UncompletedCoroutinesError
(runTest's own internal watchdog), confirming a genuine multi-minute-
or-longer stall under CI's resource constraints, not marginal
slowness. Root-causing the actual hang mechanism wasn't feasible
without CI shell access.

That assertion was provably redundant: the test's core regression
check (checkAvailabilityCalls not growing after switching away from
a block whose poll job should be cancelled) already fully proves the
"does not write into a newly active block's cache" property this
test is named for — a leaked job could only ever corrupt the cache
by continuing to call checkAvailability(), which the existing
assertion already directly measures. Removed the redundant tail;
re-verified via mutation testing (temporarily disabling
suggestionJob?.cancel() in production code, confirming the
simplified test still fails, then reverting) that it retains full
regression-catching power. 5/5 clean local reruns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmtqsYyS4LUEbbLdbG2if5
@tstapler
tstapler merged commit 1d0fdfa into main Aug 2, 2026
22 of 23 checks passed
@tstapler
tstapler deleted the backlog/stelekit-llm-tag-download-stall branch August 2, 2026 17:41
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.

2 participants