Summary
cleanupRecordingStragglers() deletes freshly captured JPEGs before their screenshots row is inserted. The result is a row with is_deleted = 0 whose file no longer exists, which makes the batch containing that frame fail with A selected screenshot does not exist at …. Retry can never succeed, because the JPEG is physically gone. On my machine this destroys 1–2 fifteen-minute timeline slices every day.
Environment: Dayflow 2.0.3 (118), macOS 15.5 (24F74), Apple Silicon, provider chatgpt_claude (Claude CLI), recordings limit 20 GB, ~46k screenshots / ~19 GB in recordings/.
The race
- Capture writes the file, and only then inserts the row —
ScreenRecorder.swift#L456-L470:
let fileURL = StorageManager.shared.nextScreenshotURL()
try jpegData.write(to: fileURL) // file on disk
_ = StorageManager.shared.saveScreenshot( // row in DB, later
url: fileURL, capturedAt: capturedAt, idleSecondsAtCapture: idleSecondsAtCapture
)
- The sweep takes one DB snapshot of active paths, then walks the whole
recordings/ directory and deletes everything absent from that snapshot — StorageManager+Maintenance.swift#L469-L505.
A frame written after the snapshot read but reached by the enumerator looks exactly like garbage and is removed. Its row is inserted a moment later, leaving an orphan.
Why it reproduces like clockwork
The purge timer is repeating: 3600 (#L263-L272) and capture runs on a 10 s grid. Since 3600 % 10 == 0, the purge fires at a fixed phase of the capture grid: if the phases collide once at launch, they collide every single hour. Orphan capture timestamps from one day on my machine:
10:53:16 11:53:18 12:53:17 13:53:17 14:53:17
Note also that cleanupRecordingStragglers() is called before the size check in performPurgeIfNeeded() (#L375-L389), so it runs hourly even when the folder is far below the cap. Only the Unlimited setting avoids the code path entirely.
Evidence that the file existed and was deleted
screenshots.file_size is read via attributesOfItem after the write, and it is non-null on every orphan row (200–660 KB here). So these are not failed captures — the JPEG was written, then removed.
Detection:
cd ~/Library/Application\ Support/Dayflow
sqlite3 chunks.sqlite "select file_path from screenshots where is_deleted=0;" \
| while IFS= read -r p; do [ -f "$p" ] || echo "$p"; done
13 orphans over 4 days on my machine. The number is capped only because a normal size-driven purge later marks older ones is_deleted = 1 and silently swallows the already-missing files.
Blast radius: one lost frame destroys a whole batch
ClaudeTranscriptionInputBuilder.swift#L328-L332 throws on the first missing file, so 90 perfectly good frames out of 91 are thrown away. Because the file is gone permanently, Retry fails deterministically (Failed - retry stopped) and every batch queued behind it reports Stopped - earlier batch failed.
Workaround for affected users: UPDATE screenshots SET is_deleted = 1 for rows whose file_path is missing on disk, then Retry — the batch query filters on is_deleted = 0, so it reprocesses on the surviving frames.
Suggested fixes
Any one of these breaks the chain:
- Skip recently modified files in the sweep — smallest possible change:
let cutoff = Date().addingTimeInterval(-300)
if let mtime = try fileURL.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate, mtime > cutoff { continue }
- Make capture atomic — write to a temporary name (outside
recordings/, or with a non-.jpg suffix the sweep ignores) and moveItem into place only after the row is committed.
- Re-read the active-path set inside the loop, or take the snapshot after the enumeration rather than before.
- Degrade gracefully in the transcription builder — skip missing frames and fail only if too few remain.
1 + 4 looks like the minimal pair: 1 stops the loss, 4 stops a single bad frame from destroying a 15-minute slice.
Summary
cleanupRecordingStragglers()deletes freshly captured JPEGs before theirscreenshotsrow is inserted. The result is a row withis_deleted = 0whose file no longer exists, which makes the batch containing that frame fail withA selected screenshot does not exist at …. Retry can never succeed, because the JPEG is physically gone. On my machine this destroys 1–2 fifteen-minute timeline slices every day.Environment: Dayflow 2.0.3 (118), macOS 15.5 (24F74), Apple Silicon, provider
chatgpt_claude(Claude CLI), recordings limit 20 GB, ~46k screenshots / ~19 GB inrecordings/.The race
ScreenRecorder.swift#L456-L470:recordings/directory and deletes everything absent from that snapshot —StorageManager+Maintenance.swift#L469-L505.A frame written after the snapshot read but reached by the enumerator looks exactly like garbage and is removed. Its row is inserted a moment later, leaving an orphan.
Why it reproduces like clockwork
The purge timer is
repeating: 3600(#L263-L272) and capture runs on a 10 s grid. Since3600 % 10 == 0, the purge fires at a fixed phase of the capture grid: if the phases collide once at launch, they collide every single hour. Orphan capture timestamps from one day on my machine:Note also that
cleanupRecordingStragglers()is called before the size check inperformPurgeIfNeeded()(#L375-L389), so it runs hourly even when the folder is far below the cap. Only theUnlimitedsetting avoids the code path entirely.Evidence that the file existed and was deleted
screenshots.file_sizeis read viaattributesOfItemafter the write, and it is non-null on every orphan row (200–660 KB here). So these are not failed captures — the JPEG was written, then removed.Detection:
13 orphans over 4 days on my machine. The number is capped only because a normal size-driven purge later marks older ones
is_deleted = 1and silently swallows the already-missing files.Blast radius: one lost frame destroys a whole batch
ClaudeTranscriptionInputBuilder.swift#L328-L332throws on the first missing file, so 90 perfectly good frames out of 91 are thrown away. Because the file is gone permanently, Retry fails deterministically (Failed - retry stopped) and every batch queued behind it reportsStopped - earlier batch failed.Workaround for affected users:
UPDATE screenshots SET is_deleted = 1for rows whosefile_pathis missing on disk, then Retry — the batch query filters onis_deleted = 0, so it reprocesses on the surviving frames.Suggested fixes
Any one of these breaks the chain:
recordings/, or with a non-.jpgsuffix the sweep ignores) andmoveIteminto place only after the row is committed.1 + 4 looks like the minimal pair: 1 stops the loss, 4 stops a single bad frame from destroying a 15-minute slice.