Skip to content

feat(hid): persist the immutable probe cache across restarts - #564

Open
tagawa0525 wants to merge 5 commits into
AprilNEA:masterfrom
tagawa0525:feat/probe-cache-persistence
Open

feat(hid): persist the immutable probe cache across restarts#564
tagawa0525 wants to merge 5 commits into
AprilNEA:masterfrom
tagawa0525:feat/probe-cache-persistence

Conversation

@tagawa0525

Copy link
Copy Markdown
Contributor

Summary

The probe cache dies with the agent process, so every restart re-interviews every device: wasted HID++ round-trips on healthy transports, and a permanently blank identity on degraded ones (a device that answered its feature walk once may not answer it again behind a lossy path). This persists the immutable slice of the probe cache to <data_dir>/probe-cache.json and warm-starts it on agent startup, so a restart resumes with everything the agent already learned.

Only receiver-paired keys are persisted (they are stable across sessions), the file is schema-versioned, and writes are atomic (temp file + rename). Enumerator::persisted() is a composable modifier, so the agent's enumerator carries both persistence and the channel registry.

Related: #278 (the ledger-replay / probe-cache grace interaction on Bolt partial-walk recovery — a persistent cache changes that calculus for the better, though this PR does not claim to close it).

Changes

  • hid: new inventory/persist.rs — schema-versioned, atomically written probe-cache store; Enumerator::persisted() modifier; warm-start load in the enumerator (inventory.rs, inventory/features.rs).
  • hid: tempfile added as a dev-dependency for the persistence tests (already in the workspace lockfile; the gpui/gpui-component pins are untouched).
  • agent-core: the agent's inventory watcher composes persisted() with with_registry (watchers/inventory.rs).

Testing

  • cargo fmt --all -- --check / cargo clippy --workspace --all-targets -- -D warnings / cargo test --workspace — green on this branch; new unit tests cover the disk round-trip (probe_cache_roundtrips_through_disk) and tolerance of missing/garbage cache files (probe_cache_load_tolerates_missing_or_garbage_files). Schema-version mismatch falls back to an empty cache in load().
  • Not runtime-tested on hardware: the concrete check is to start the agent, let it enumerate a Bolt receiver, restart the agent, and confirm devices surface with full model info before the first probe tick completes.

The immutable probe cache dies with the process, so every agent restart
re-interviews every device — wasted round-trips on healthy transports
and a permanently blank identity on degraded ones (a device that never
completes a walk after a restart has nothing to fall back to). Pin the
intended contract: a saved cache round-trips through disk with its
model info and battery index intact, keyed by the stable receiver-
paired identities, and loaded entries restart the refresh clock.
Ignore-marked until the implementation lands.
The probe cache lived only in process memory, so every agent restart
re-interviewed every device — wasted round-trips on healthy transports,
and a permanently blank identity on degraded ones where a fresh walk
keeps failing. Write the receiver-paired entries (model info,
capabilities, battery feature index) through to
<data_dir>/probe-cache.json — atomically, on fresh probes and evictions
only, never for per-tick battery refreshes — and warm-start the
watcher's enumerator from it. Direct-node identities are not persisted
(an OS node id has no cross-boot stability). Loaded entries restart the
refresh clock, so the periodic self-healing re-walk still owns
staleness. The RED tests from the previous commit are un-ignored.
Copilot AI lite review requested due to automatic review settings August 11, 2026 07:06
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

The PR persists immutable Bolt probe data across agent restarts while excluding unstable Unifying-slot and direct-device keys.

  • Adds schema-versioned probe-cache serialization with atomic replacement and tolerant cold-start fallback.
  • Loads persisted entries into the long-lived agent enumerator and flushes only meaningful Bolt cache changes.
  • Adds coverage for disk round trips, invalid cache files, persistable-key filtering, and volatile battery exclusion.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previous Unifying-slot collision is fixed by persisting only Bolt entries keyed by freshly read device-owned unit IDs.

Important Files Changed

Filename Overview
crates/openlogi-hid/src/inventory/persist.rs Implements versioned Bolt-only probe persistence, strips volatile battery readings, and safely falls back to a cold cache on load errors.
crates/openlogi-hid/src/inventory.rs Integrates warm-start loading, dirty tracking, eviction persistence, and end-of-tick flushing into the enumerator lifecycle.
crates/openlogi-hid/src/inventory/features.rs Adds serde support to the immutable probe structures needed by the persisted cache.
crates/openlogi-agent-core/src/watchers/inventory.rs Enables persistence on the agent's existing long-lived, registry-aware enumerator.
crates/openlogi-hid/src/inventory/tests.rs Covers Bolt-only persistence, volatile battery omission, dirty tracking, schema mismatch, and malformed or missing cache files.
crates/openlogi-hid/Cargo.toml Adds serde_json at runtime and tempfile for persistence tests.
Cargo.lock Records the openlogi-hid dependency additions without introducing new affected package versions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Agent[Agent startup] --> Enumerator[Persistent Enumerator]
  Enumerator --> Load[Load probe-cache.json]
  Load --> BoltKey[Restore Bolt entries by unit ID]
  BoltKey --> Poll[Enumerate receiver slots]
  Poll --> Identity[Read current pairing identity]
  Identity --> Hit{Unit ID cache hit?}
  Hit -->|Yes| Reuse[Reuse immutable probe]
  Hit -->|No| Probe[Run full feature probe]
  Probe --> Dirty[Mark Bolt cache dirty]
  Reuse --> Refresh[Periodic self-healing refresh]
  Refresh --> Dirty
  Dirty --> Save[Write temporary file and rename]
Loading

Reviews (3): Last reviewed commit: "fix(hid): only dirty the persisted cache..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/inventory/persist.rs Outdated

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.

Pull request overview

Persists the HID probe cache’s receiver-paired, “immutable” probe results to disk so the agent can warm-start device identity/capability data after restarts, reducing expensive HID++ re-probing and improving resilience on degraded transports.

Changes:

  • Add a schema-versioned JSON persistence layer for the probe cache, with warm-start load and best-effort atomic save.
  • Extend Enumerator with a persisted() modifier, and flush the cache to disk only when “immutable” cache contents change (full probes / evictions).
  • Compose the persistent enumerator into the agent-core inventory watcher; add unit tests and dev-dependency support.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/openlogi-hid/src/inventory/persist.rs New persistence module for saving/loading the cache to <data_dir>/probe-cache.json.
crates/openlogi-hid/src/inventory.rs Adds persisted() and cache flush plumbing, plus dirty-tracking.
crates/openlogi-hid/src/inventory/features.rs Makes probe result types serializable for persistence.
crates/openlogi-hid/src/inventory/tests.rs Adds disk round-trip and “missing/garbage file” tolerance tests.
crates/openlogi-hid/Cargo.toml Adds serde_json dependency and tempfile dev-dependency.
crates/openlogi-agent-core/src/watchers/inventory.rs Uses .persisted() in the agent inventory watcher enumerator.
Cargo.lock Records dependency graph updates (serde_json/tempfile).
Suppressed comments (1)

crates/openlogi-hid/src/inventory.rs:662

  • Similarly, evict_unseen marks cache_dirty for evictions of CacheKey::Direct(_) entries even though Direct keys are never persisted. This can trigger unnecessary writes of an unchanged on-disk cache. Consider only setting cache_dirty when an evicted key is persistable (Bolt/UnifyingSlot).
            if *misses > CACHE_MISS_GRACE {
                self.cache.remove(&key);
                self.misses.remove(&key);
                self.cache_dirty = true;
            }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +74 to +83
let entries: Vec<PersistedEntry> = cache
.iter()
.filter_map(|(key, cached)| {
persistable(key).map(|key| PersistedEntry {
key,
probe: cached.probe.clone(),
battery: cached.battery,
})
})
.collect();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4eea551 — save() now strips the volatile reading (probe.battery = None) so only immutable identity/capabilities go to disk; the immutable battery feature index (PersistedEntry::battery) still persists so warm-started entries keep the one-round-trip battery refresh. The round-trip test asserts the reading does not survive.

Comment on lines +622 to +629
CacheOutcome::Fresh(key, cached) => {
seen_keys.insert(key.clone());
self.cache.insert(key, cached);
// A completed full probe is worth writing through; battery
// `Update`s are not (they would rewrite the file every
// tick for a value that is re-read live anyway).
self.cache_dirty = true;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0d477d5 — cache_dirty is now gated on persist::is_persistable(&key) at both sites (fresh probes and evictions), with a test pinning that non-persistable churn never dirties the file and a Bolt probe still does.

…air-safe

A UnifyingSlot cache key is receiver + slot, so a different device paired
into that slot while the agent is down would inherit the previous
occupant's identity, capabilities, and battery indexing on warm start.
Bolt keys are the device's own pairing-register unit id, which no
re-pairing can silently reassign — persist those alone (schema v2).

Refs: AprilNEA#564 (comment)
`ProbedFeatures.battery` is a live reading, re-read on every cache hit —
writing it to disk resurrected a stale percentage after restarts and
contradicted the module's immutable-only contract. Strip it at save
time; the immutable battery feature index still persists so warm-started
entries keep their one-round-trip battery refresh.

Refs: AprilNEA#564 (comment)
Fresh probes and evictions of keys `persist::save` filters out (direct
nodes, Unifying slots) were still setting `cache_dirty`, so a system
with only such devices rewrote an unchanged probe-cache.json on every
refresh pass. Gate the flag on persistability.

Refs: AprilNEA#564 (comment)
@tagawa0525

Copy link
Copy Markdown
Contributor Author

@greptileai review

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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (1)

crates/openlogi-hid/src/inventory/persist.rs:112

  • save() uses std::fs::rename(&tmp, path) to atomically replace the cache file. On Windows, std::fs::rename fails if the destination already exists, so the probe cache will never successfully update after the first write (and will keep logging persist failures). Consider using a Windows-specific atomic replace (e.g., MoveFileExW with MOVEFILE_REPLACE_EXISTING) and falling back to std::fs::rename elsewhere.
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, path)

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