fix(chat): keep open artifacts in sync with disk, with calm stale-state handling - #179
Conversation
Poll open artifact fingerprints while visible, reload stable text and image changes without flicker, and retain last-good content behind an explicit divergence warning when disk reads fail. Adapted from Brandon Sherman's format-patch attached to BOT-1675. Signed-off-by: Matt Toohey <contact@matttoohey.com>
Track cross-platform change times and signed pre-epoch mtimes, serialize forced refreshes against polling, and only accept image fingerprints after the rendered cache-busted source decodes. Cover metadata-preserving rewrites, refresh races, image URL validation, and empty-file recovery. Signed-off-by: Matt Toohey <contact@matttoohey.com>
Recheck image fingerprints after asynchronous decode before accepting cache-busted content, and run polled metadata inspection on Tokio's blocking pool. Add focused coverage for decode-time file changes and async metadata execution. Signed-off-by: Matt Toohey <contact@matttoohey.com>
Poll visible artifacts every ten seconds while Berd is unfocused, check immediately when focus returns, and restore the foreground interval. Cover background timing and focus recovery. Signed-off-by: Matt Toohey <contact@matttoohey.com>
…dling
**Category:** fix
**User Impact:** The artifact viewer no longer flashes a stale warning
for routine one-off file races, visibly dims content that really is
stale, and tells users when the file was deleted instead of offering a
reload that cannot succeed.
**Problem:** The viewer flagged the view as diverged on the very first
failed poll cycle, including torn writes where the file was simply
mid-rewrite, so agents rewriting files tripped the warning constantly.
The single warning message also conflated "the file changed but can't
be read" with "the file is gone", and offered a pointless Reload button
for deleted files.
**Solution:** Polling failures now get a two-strike grace period: one
failed cycle changes nothing, two consecutive failures flag the view.
Torn-write fingerprint mismatches neither flag nor strike — the next
cycle retries against the settled file. User-initiated reloads bypass
the grace period so the user gets an immediate answer, and any
successful cycle resets the streak. The Rust `stat_file` command now
returns a structured error distinguishing missing files from other
failures, the warning strip shows deletion-specific copy without a
Reload button when the file is gone, and diverged content dims to 60%
opacity so the strip clearly describes it.
## Verification
- `just check`
- `just test` (6,900 passed, 1 skipped)
- `just tauri-check`
- `just tauri-test`
- `just clippy`
- `just tauri-fmt-check`
<details>
<summary>File changes</summary>
**src-tauri/src/commands/system.rs**
Return a structured `FileStatError { kind, message }` from `stat_file`
with a `missing`/`other` kind so deletion survives the IPC boundary;
update the stat tests and pin the serialized discriminant.
**src/shared/api/system.ts**
Add the `FileStatErrorKind`/`FileStatError` types and the
`fileStatErrorKind()` narrowing helper for command rejections.
**src/features/chat/ui/ArtifactViewer.tsx**
Add the two-strike divergence grace period, treat torn-write
fingerprint mismatches as no verdict, flag failed user reloads
immediately, dim diverged content, and split the warning strip into
deleted (no Reload) and unreadable (with Reload) cases.
**src/features/chat/ui/__tests__/ArtifactViewer.test.tsx**
Cover the grace period, torn-write healing, deleted vs unreadable
copy and Reload visibility, immediate user-reload flagging, dimming,
and strike reset on recovery.
**src/shared/i18n/locales/en/chat.json** /
**src/shared/i18n/locales/es/chat.json**
Replace `artifactViewer.diskDiverged` with `fileDeleted` and
`fileUnreadable` in both locales.
</details>
Co-authored-by: goose <goose@block.xyz>
|
🤖 (posted by Brandon's agent; verification run by the agent, reviewed by Brandon) Manual verification of this branch (599ec74) on macOS via
Screenshots of each state are on the Linear ticket (BOT-1675, latest comment) — Linear-hosted images don't render cross-origin here. One observation for review: the |
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
REQUEST_CHANGES: one blocking lifecycle issue remains. The new refresh/poll pipeline has no presentation timeout, so a metadata or content request that never settles can leave an initial viewer loading forever or permanently stop an already-open viewer from polling. Supplied GitHub evidence was inspected: all eight reported check runs completed successfully; required checks still independently govern merge readiness.
Deterministic publication result: 1 blocking and 0 non-blocking finding(s) publishable; 0 duplicate(s) suppressed.
| void contentReadRevision; | ||
| void (async () => { | ||
| try { | ||
| const before = await statFile(artifact.resolvedPath); |
There was a problem hiding this comment.
🤖 P1 · Bound filesystem waits (blocking)
The forced refresh awaits statFile/readTextFile without any presentation timeout. The native implementation explicitly moves metadata work off-thread because remote or removable filesystems may wait, but spawn_blocking does not bound that wait. If any invoke never settles, forcedRefreshInFlightRef remains true and suppresses every poll; similarly, a never-settling poll never reaches its finally/scheduleNextPoll chain. This creates a permanent stuck state rather than the promised degraded stale/error path.
User effect: Opening an artifact on a stalled filesystem can show the loading spinner forever, while an already-open artifact can silently stop checking for changes forever with no warning or recovery action.
Recommended fix: Add a presentation timeout around each stat/read/decode cycle. On initial load, leave the blocking state and show the existing load/error fallback; with last-good content, count the timeout as a divergence failure while allowing later poll cycles or a manual reload to proceed. Invalidate late results by generation so timed-out work cannot overwrite newer state.
Test: Add fake-timer tests where statFile and readTextFile each return a never-resolving promise; assert initial loading degrades to an actionable error, last-good content becomes stale after the intended grace period, subsequent polling continues, and a late completion cannot replace newer content.
There was a problem hiding this comment.
🤖 Fixed in d97bde3. Every stat/read/decode step in both the forced-refresh and poll effects is now bounded by withPresentationTimeout (PRESENTATION_TIMEOUT_MS = 10s), which rejects with a PresentationTimeoutError that flows through the existing catch/finally paths — so a hang gets the same treatment as a read failure (error state on initial load, divergence strikes against last-good content), and the finally blocks clear forcedRefreshInFlightRef/checkInFlight and reach scheduleNextPoll, so polling can no longer wedge.
Late settlement of a timed-out invoke is inert by construction: it merely re-settles an already-rejected wrapper (a no-op under settle-once semantics), so the abandoned cycle's continuation never runs and stale bytes can't overwrite newer state — the existing generation guards stay in place as a second line of defense.
Covered by four new fake-timer tests, including one that resolves the hung promise after a newer successful cycle and asserts the newer content stands.
**Category:** fix **User Impact:** A hung filesystem operation (stalled network mount, yanked removable media) can no longer wedge the artifact viewer into a permanent spinner or silently kill freshness polling. After ten seconds the hang surfaces through the same error state and stale-warning strip as any other read failure, and polling keeps running so the view heals on its own once the filesystem recovers. **Problem:** Every stat/read/image-decode await in the freshness machinery was unbounded. The Tauri filesystem commands run under spawn_blocking precisely because these syscalls can hang indefinitely, and a never-settling invoke had three failure modes: an initial load spun forever with no error; a hung forced refresh left `forcedRefreshInFlightRef` stuck true, suppressing every future poll; and a hung poll never cleared `checkInFlight` or reached `scheduleNextPoll`, silently ending polling. No warning, no recovery. **Solution:** A `withPresentationTimeout()` helper bounds each stat/read/decode step at `PRESENTATION_TIMEOUT_MS` (10s — comfortably beyond any healthy local operation), rejecting with a distinguishable `PresentationTimeoutError`. The rejection flows into the existing catch/finally blocks, so timeouts get exactly the established failure semantics: initial loads show the error state and flag immediately, polls against last-good content consume divergence strikes, and user-initiated reloads flag without grace. Crucially the finally blocks now run on timeout, clearing `forcedRefreshInFlightRef`/`checkInFlight` and rescheduling the next poll. A late settlement of the timed-out promise only re-settles the already-rejected wrapper — a spec-level no-op — so stale bytes can never overwrite newer state; the existing generation guards remain as an independent second line of defense. ## Verification - `just check` - `just test` (6,904 passed, 1 skipped) <details> <summary>File changes</summary> **src/features/chat/ui/ArtifactViewer.tsx** Add `PRESENTATION_TIMEOUT_MS`, `PresentationTimeoutError`, and the `withPresentationTimeout()` wrapper; apply it to every stat, text read, and image preload in both the forced-refresh effect and the poll effect. **src/features/chat/ui/__tests__/ArtifactViewer.test.tsx** Cover a hung initial load timing out into the error state with polling proceeding, hung reads consuming the two-strike grace period, polling continuing after a timed-out cycle, and a late settlement of a timed-out read staying inert after newer content lands. </details> Co-authored-by: goose <goose@block.xyz>
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
APPROVE: the prior presentation-stall defect is addressed with bounded renderer waits, recovery scheduling, late-result isolation, and discriminating tests. No new publishable findings remain after full review. One residual concern about repeated native operations after renderer timeouts is suppressed because it is the same underlying issue as the existing unresolved automation thread, which has a substantive human-account reply. Supplied GitHub evidence was inspected: six checks succeeded and two checks (Frontend build smoke and Transcript virtualization) were still in progress; required checks independently govern merge readiness.
Deterministic publication result: 0 blocking and 0 non-blocking finding(s) publishable; 1 duplicate(s) suppressed.
Pending checks: 1 check(s) are not complete.
This approval reflects the completed code review only; merge readiness remains governed by the repository's required checks.
Category: fix
User Impact: Documents and images open in the artifact viewer now track what's actually on disk — external edits, shell writes, and subagent output appear automatically — and when the view genuinely can't match the file, the viewer says exactly why (changed-but-unreadable vs deleted), dims the stale content, and recovers on its own when the file settles.
Problem: The artifact viewer rendered a one-shot snapshot. Files rewritten by shell commands, external editors, or delegated subagents drifted silently out of date, images stayed cached at their old bytes, and there was no signal that the preview no longer matched disk. Early iterations of polling then overcorrected: a single mid-rewrite race or transient I/O error instantly branded the view stale, one message covered every failure mode, and deleted files offered a Reload button that could never succeed.
Solution: The viewer polls the open file's metadata fingerprint (size + mtime + ctime, including the Windows change time) every 1.5s in the foreground and 10s in the background, checking immediately on focus and pausing while hidden. Content swaps use a stat → read → stat-confirm cycle so torn writes are never rendered or judged — a mismatched confirm is treated as "no verdict" and the next cycle retries. Images preload the cache-busted URL off-screen and swap only after decode, so the rendered image never flashes broken. Real failures get a two-strike grace period before the warning shows; a user-initiated Reload bypasses it for an immediate answer, and any successful cycle clears the warning and resets the streak.
stat_filenow returns a structured error distinguishing a missing file from other failures: deletion shows "File deleted from disk." with no Reload button (polling self-heals if it reappears), other failures show "File changed but can't be read." with Reload, and diverged content dims to 60% opacity beneath the strip.Product behavior
Warning-strip copy is easy to change — happy to bikeshed the exact wording in review.
Verification
just check,just test(6,900 passed, 1 skipped),just tauri-check,just tauri-test,just clippy,just tauri-fmt-checkContext: reported and root-caused in BOT-1675.
File changes
src-tauri/src/commands/system.rs
Add the
stat_filecommand returning the file's metadata fingerprint (size, mtime, ctime — viaFILE_BASIC_INFOon Windows) off the async command thread, with a structuredFileStatError { kind: missing | other, message }so deletion survives the IPC boundary.src-tauri/Cargo.toml
Adds the Windows filesystem API feature needed to read file change-time metadata.
src-tauri/src/lib.rs
Registers the new
stat_filecommand with the Tauri invoke handler.src/shared/api/system.ts
Add
statFile(), theFileStatPayload/FileStatErrortypes, and thefileStatErrorKind()rejection-narrowing helper.src/features/chat/ui/ArtifactViewer.tsx
Poll the open artifact with visibility/focus-aware intervals; detect changes by fingerprint with stat → read → stat-confirm torn-write protection; preload-swap images; apply the two-strike grace period with immediate user-reload bypass; dim diverged content; show deleted vs unreadable warning strips.
src/features/chat/ui/tests/ArtifactViewer.test.tsx
Cover polling swap, background/focus timing, ctime-only rewrites, image preload/decode races, the grace period, torn-write healing, deleted vs unreadable copy and Reload visibility, immediate user-reload flagging, and recovery.
src/shared/i18n/locales/en/chat.json / src/shared/i18n/locales/es/chat.json
Add
artifactViewer.fileDeleted,fileUnreadable, andreloadstrings in both locales.