fix(desktop): bind screenshot capture to the server entry id, not the local one - #80
Merged
bladehell-ai merged 1 commit intoAug 21, 2026
Conversation
… local one Screenshots stopped reaching the dashboard while time sync stayed healthy. On 2026-08-20 seventeen users tracked 5-8 hours each and three had any screenshot at all. Live uploads (arrival within 60s of capture) went 1627/1639 on Aug 12 to 0/835 on Aug 18 -- the offline-first rollout landed on Aug 13. `startTimer()` makes no network call any more, so `currentEntry.id` is the local SQLite id `local-<ts>-<rand>`, and it was passed straight to `screenshotService.start()`. `POST /screenshots/presign` validates `time_entry_id` as a uuid and looks it up in `time_entries`, so every live shot 422'd, exhausted its retries and fell into the offline queue. `rebindEntryId()` was written for exactly this case and survived the refactor, but its only caller -- `reconcileTimerState()` -- was deleted with it. The queued fallback then lost them for good. Resolution reads `timer_sessions.server_entry_id`, but the 05:00 purge and `clearForLogout()` delete that row with no knowledge of the queue's separate database, and with a 15-42 hour backlog lag shots routinely outlived their own session. The `idempotency_key` fallback could not help: the queued key is the shot's dedupe key, never the session's. Unresolvable items are held rather than dropped, so they piled up behind `LIMIT 500` and were finally deleted -- silently, with their image files -- by the 7-day TTL sweep. Heartbeats were unaffected because they are priority 1 and carry the session uuid, which is what isolated the fault. - `SessionSyncWorker` fires `onSessionConfirmed(localId, serverEntryId)` once per session, on the transition to a known server id; index.js rebinds capture to it - `liveCaptureEntryId()` seeds `start()` from an already-known server id, covering restore-after-restart and project switch - queued screenshots record `session_uuid`; `add()` persists it and `_resolveEntryId()` resolves through it first - `purgeConfirmed()` takes a keep-list fed by `offlineQueue.referencedSessionKeys()` - a flush that holds screenshots now logs it, escalating past 50 12 regression tests in desktop/test/screenshot-entry-id-binding.test.js (46 suites / 796 tests pass). Full write-up in bugs/desktop-screenshots-bound-to-local-entry-id.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bladehell-ai
deleted the
fix/desktop-screenshots-bound-to-local-entry-id
branch
August 21, 2026 08:03
bladehell-ai
restored the
fix/desktop-screenshots-bound-to-local-entry-id
branch
August 21, 2026 09:15
bladehell-ai
deleted the
fix/desktop-screenshots-bound-to-local-entry-id
branch
August 21, 2026 09:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
Screenshots stopped reaching the dashboard while time sync stayed healthy. On 2026-08-20, 17 users tracked 5–8 hours each and 3 had any screenshot at all. Each install stopped on its own date, which made it look like an intermittent per-machine problem.
Evidence (production)
Screenshots arriving within 60s of capture (
created_at - captured_at):The live path died the exact day the offline-first refactor reached production. Everything after it dribbles in through the offline queue: mean capture→arrival lag 15–42 hours, with 833 shots in the last 7 days taking over 24h.
All 17 users were sending heartbeats up to the current minute — that ruled out connectivity, auth, sync and the S3 pipeline, and isolated the fault to the screenshot path.
Root cause
startTimer()makes no network call any more, socurrentEntry.idis the local SQLite idlocal-<ts>-<rand>, and it was passed straight toscreenshotService.start().POST /screenshots/presignvalidatestime_entry_idas a uuid andfirstOrFail()s it againsttime_entries, so every live screenshot 422'd, exhausted its 3 retries and fell into the offline queue.rebindEntryId()was written for exactly this and survived the refactor — but its only caller,reconcileTimerState(), was deleted with it. Its docblock still named that caller.Why the queued fallback then lost them for good: resolution reads
timer_sessions.server_entry_id, but the 05:00 purge (24h) andclearForLogout()delete that row with no knowledge of the queue's separate database. With a 15–42h backlog lag, shots routinely outlived their own session row. Theidempotency_keyfallback could not help — the queued key is the shot's dedupe key, never the session's. Unresolvable items are held, not dropped, so they accumulated behindORDER BY priority DESC, id ASC LIMIT 500and were finally deleted silently, with their image files, by the 7-day TTL sweep.Changes
SessionSyncWorkerfiresonSessionConfirmed(localId, serverEntryId)once per session, on the transition from "server has never seen this" to "id known";index.jswires it toscreenshotService.rebindEntryId(). This is the only signal that can un-break a session started offline, since capture is bound before any server id exists.liveCaptureEntryId()seedsstart()from an already-known server id — covers restore-after-restart, project switch, and any session older than one sync cycle.session_uuidalongside the per-shotidempotency_key;add()persists it and_resolveEntryId()resolves through it first.purgeConfirmed()takes a keep-list, fed byofflineQueue.referencedSessionKeys().console.errorpast 50 — the failure mode above was completely silent.Tests
desktop/test/screenshot-entry-id-binding.test.js— 12 new tests covering the rebind callback (fires once, never re-fires, survives a throwing handler), the index.js wiring,session_uuidpersistence and resolution precedence, and the purge keep-list.46 suites / 796 tests pass.
Docs
bugs/desktop-screenshots-bound-to-local-entry-id.md(+ index row inbugs/README.md)CLAUDE.md— the capture-binding rule, thesession_uuidrequirement, and the purge keep-listReviewer notes
currentEntry.idis deliberately not mutated — only the capture service is rebound. Local SQLite operations key off it and would break.session_uuidwidening is safe for heartbeats: theiridempotency_keyalready is the session uuid, so preferringsession_uuidonly widens what can resolve.🤖 Generated with Claude Code