Skip to content

fix(gui): prefetch the asset index without devices and re-arm sync for new models#220

Merged
AprilNEA merged 1 commit into
masterfrom
fix/asset-sync-bootstrap
Jun 12, 2026
Merged

fix(gui): prefetch the asset index without devices and re-arm sync for new models#220
AprilNEA merged 1 commit into
masterfrom
fix/asset-sync-bootstrap

Conversation

@AprilNEA

Copy link
Copy Markdown
Owner

Problem

The background asset sync only fired once an agent snapshot carried at least one device with HID++ model info. On a machine whose receiver reads are flaky (#218), that single gate can never open: no model info → no sync → no index.json → every device stays on the synthetic silhouette, and nothing ever retries the bootstrap. The reporter's GUI logged no asset index found — using synthetic silhouette on every poll with a permanently empty ~/.local/share/openlogi/assets.

Change

Decouple the index prefetch from the model gate, and make the sync re-armable:

  • Index prefetch needs no devices. The first agent snapshot — even a deviceless one — triggers a sync run; sync() already fetches index.json before looking at models, so an empty model list now lands the registry on disk ready for the first device sighting.
  • Depot fetches re-arm per model. Successful runs fold their model keys into a session HashSet; a model set that grows later (device wakes up, new pairing) starts a new run instead of being latched off by the old session-wide SYNC_DONE.
  • Outcome channel replaces the AtomicU8 state machine. A single-flight flag plus a SyncOutcome channel into the select loop; the resolver now rebuilds after every successful run (previously only the first), so renders landing in a later round still become visible.

Failure backoff is unchanged (1s doubling to 60s, first attempt immediate); success resets it so a newly paired device syncs without waiting out a stale failure delay. OPENLOGI_SYNC and the bundled-assets release gate (should_run) behave as before, checked once at startup.

Notes

Testing

  • cargo clippy -p openlogi-gui --all-targets clean, full-workspace pre-push clippy clean.
  • cargo test -p openlogi-gui: 27 passed.
  • Trigger/backoff logic traced against the existing sync_retry_delay unit tests; sync()'s index-before-models order is pre-existing, covered by reading fetch_index_to_dir ahead of the targets.is_empty() early-return.

…r new models

The background asset sync only fired once a snapshot carried model info,
so a machine whose receiver reads are flaky (#218) could miss its one
chance: no model info, no sync, no index.json — every device stranded on
the synthetic silhouette with nothing retrying the bootstrap.

Split the trigger from the model gate:

- The first agent snapshot — even a deviceless one — prefetches the
  index, so resolution works the moment a device first appears.
- Depot fetches fire per model key and fold into a synced set on
  success, so a model set that grows later re-arms the sync instead of
  being latched off by the session-wide DONE state.
- The AtomicU8 state machine becomes a single-flight flag plus an
  outcome channel into the select loop, which now also rebuilds the
  resolver after every successful run, not just the first.

Failure backoff (1s doubling to 60s) is unchanged; success resets it so
a newly paired device syncs immediately.
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR decouples the background asset-sync trigger from requiring an initial device snapshot with model info. The first agent snapshot — even a deviceless one — now fires an index prefetch, and per-depot syncs re-arm whenever new model keys appear in synced_keys, replacing the old session-wide SYNC_DONE latch that permanently blocked syncing after the first run.

  • The AtomicU8 state machine (SYNC_IDLE/RUNNING/DONE/FAILED) is replaced by a sync_running: bool flag plus a SyncOutcome channel into the select loop, with index_refreshed and a HashSet<String> of model keys tracking session progress; the if sync_running guard on the sync_rx.recv() arm correctly prevents the held sync_tx clone from blocking the else => break exit when the other channels close.
  • sync_enabled is computed once at startup from cache.has_bundle_root() rather than inside each sync call; this is intentional per the PR description and is safe for the stated use-cases (debug builds always sync, release bundles are static assets).

Confidence Score: 4/5

Safe to merge; the state-machine replacement is mechanically sound and correctly handles the select-loop exit, backoff, and re-arm edge cases.

The core logic is correct: sync_running prevents concurrent runs, if sync_running on the outcome arm keeps else => break reachable, and (!index_refreshed || !pending.is_empty()) avoids spurious re-syncs after the index prefetch. The one limitation worth watching is that per-depot failures inside sync() are non-fatal, so a model whose depot silently 404s will have its key added to synced_keys on the overall-success path and won't be retried; this was the same outcome under the old SYNC_DONE latch but is now baked into the model-key tracking design.

The re-arm logic in main.rs around lines 269–287 is the most significant change; in particular the interaction between synced_keys, per-depot error swallowing in sync.rs, and the if sync_running channel guard deserves a careful read.

Important Files Changed

Filename Overview
crates/openlogi-gui/src/main.rs Replaces AtomicU8 state machine with sync_running bool + SyncOutcome channel; adds index_refreshed flag and per-model synced_keys HashSet for re-arming; moves sync_enabled check to startup time
crates/openlogi-gui/src/asset/sync.rs Doc/comment-only update; no logic changes; correctly documents that empty models is a valid call (index-only prefetch)

Sequence Diagram

sequenceDiagram
    participant A as Agent IPC
    participant S as Select Loop
    participant T as Sync Thread
    participant FS as Filesystem

    Note over S: startup: sync_enabled = should_run(...)
    Note over S: index_refreshed=false, synced_keys={}

    A->>S: GuiUpdate::Snapshot (no devices)
    Note over S: pending=[], !index_refreshed → trigger sync
    S->>T: spawn(run_asset_sync([]))
    T->>FS: fetch index.json
    T->>S: "SyncOutcome{ok:true, keys:[]}"
    Note over S: index_refreshed=true, cache rebuilt, assets_dirty=true

    A->>S: GuiUpdate::Snapshot (device_A appears)
    Note over S: force_refresh=take(assets_dirty)=true
    Note over S: pending=[model_A] → trigger sync
    S->>T: spawn(run_asset_sync([model_A]))
    T->>FS: re-fetch index.json, fetch depot for model_A
    T->>S: "SyncOutcome{ok:true, keys:[key_A]}"
    Note over S: synced_keys={key_A}, cache rebuilt, assets_dirty=true

    A->>S: GuiUpdate::Snapshot (device_B paired)
    Note over S: pending=[model_B] → trigger sync
    S->>T: spawn(run_asset_sync([model_B]))
    T->>FS: re-fetch index.json, fetch depot for model_B
    T->>S: "SyncOutcome{ok:true, keys:[key_B]}"
    Note over S: synced_keys={key_A, key_B}
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(gui): prefetch the asset index witho..." | Re-trigger Greptile

Comment on lines 282 to 286
std::thread::spawn(move || {
let next = if sync_assets_if_needed(&inv) {
SYNC_DONE
} else {
SYNC_FAILED
};
state.store(next, Ordering::Release);
let keys = pending.iter().map(model_key).collect();
let ok = run_asset_sync(&pending);
let _ = tx.send(SyncOutcome { ok, keys });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Depot failure silently locks model out of future retries

sync() swallows per-depot errors (each sync_depot failure is logged and continued; sync() returns Ok(())). Because keys is pre-computed from pending before run_asset_sync runs, every model attempted in the run — including those whose depot download silently failed — gets folded into synced_keys on success. A device whose depot 404s or fails transiently will never be retried within the session, leaving it on the synthetic silhouette. The old SYNC_DONE latch had the same effect, so this is pre-existing behavior, but the re-arm design makes it worth noting: per-depot outcomes aren't currently distinguishable from the thread, so truly partial failures can't be singled out for retry without a more granular result type.

Fix in Codex Fix in Claude Code

@AprilNEA AprilNEA merged commit 548f98e into master Jun 12, 2026
8 checks passed
@AprilNEA AprilNEA deleted the fix/asset-sync-bootstrap branch June 12, 2026 06:18
AprilNEA added a commit to davidbudnick/OpenLogi that referenced this pull request Jun 12, 2026
Resolve the IPC poll conflict against the startup-states rework and
adapt the diagnostics adapter to master:

- call store_agent_snapshot before the status snapshot moves into
  AgentLink::Ready; the inventory merge keeps the InventoryHealth gate
  (AprilNEA#213/AprilNEA#215/AprilNEA#220)
- read accessibility from the retained AgentStatus; the
  AppState.accessibility_granted field no longer exists
- map DeviceRoute::Unifying (AprilNEA#181) to a new
  ConnectionKind::UnifyingReceiver
AprilNEA added a commit that referenced this pull request Jun 13, 2026
- Compute the asset-cache size once when the Settings window opens and
  store it on the view, instead of re-walking the cache dir on the main
  thread every render. (Greptile)
- Extract the macOS Finder reveal into a cfg-gated helper so the early
  return isn't the function's last statement on non-macOS — CI clippy
  flagged needless_return on the Linux build (local macOS clippy can't
  see that path).

The original commit also skipped the manual sync on an empty inventory
to keep the old SYNC_DONE latch from wedging the auto-sync gate; the
rebased loop has no such latch (it re-arms per model set, #220) and an
index-only refresh is first-class there (#218), so that guard is
dropped.
AprilNEA added a commit that referenced this pull request Jun 13, 2026
- Compute the asset-cache size once when the Settings window opens and
  store it on the view, instead of re-walking the cache dir on the main
  thread every render. (Greptile)
- Extract the macOS Finder reveal into a cfg-gated helper so the early
  return isn't the function's last statement on non-macOS — CI clippy
  flagged needless_return on the Linux build (local macOS clippy can't
  see that path).

The original commit also skipped the manual sync on an empty inventory
to keep the old SYNC_DONE latch from wedging the auto-sync gate; the
rebased loop has no such latch (it re-arms per model set, #220) and an
index-only refresh is first-class there (#218), so that guard is
dropped.
AprilNEA added a commit that referenced this pull request Jun 13, 2026
- Compute the asset-cache size once when the Settings window opens and
  store it on the view, instead of re-walking the cache dir on the main
  thread every render. (Greptile)
- Extract the macOS Finder reveal into a cfg-gated helper so the early
  return isn't the function's last statement on non-macOS — CI clippy
  flagged needless_return on the Linux build (local macOS clippy can't
  see that path).

The original commit also skipped the manual sync on an empty inventory
to keep the old SYNC_DONE latch from wedging the auto-sync gate; the
rebased loop has no such latch (it re-arms per model set, #220) and an
index-only refresh is first-class there (#218), so that guard is
dropped.
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.

1 participant