feat(hid): persist the immutable probe cache across restarts - #564
feat(hid): persist the immutable probe cache across restarts#564tagawa0525 wants to merge 5 commits into
Conversation
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.
Greptile SummaryThe PR persists immutable Bolt probe data across agent restarts while excluding unstable Unifying-slot and direct-device keys.
Confidence Score: 5/5The 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.
|
| 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]
Reviews (3): Last reviewed commit: "fix(hid): only dirty the persisted cache..." | Re-trigger Greptile
There was a problem hiding this comment.
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
Enumeratorwith apersisted()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_unseenmarkscache_dirtyfor evictions ofCacheKey::Direct(_)entries even thoughDirectkeys are never persisted. This can trigger unnecessary writes of an unchanged on-disk cache. Consider only settingcache_dirtywhen 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.
| let entries: Vec<PersistedEntry> = cache | ||
| .iter() | ||
| .filter_map(|(key, cached)| { | ||
| persistable(key).map(|key| PersistedEntry { | ||
| key, | ||
| probe: cached.probe.clone(), | ||
| battery: cached.battery, | ||
| }) | ||
| }) | ||
| .collect(); |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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)
|
@greptileai review |
There was a problem hiding this comment.
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()usesstd::fs::rename(&tmp, path)to atomically replace the cache file. On Windows,std::fs::renamefails 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.,MoveFileExWwithMOVEFILE_REPLACE_EXISTING) and falling back tostd::fs::renameelsewhere.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
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.jsonand 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: newinventory/persist.rs— schema-versioned, atomically written probe-cache store;Enumerator::persisted()modifier; warm-start load in the enumerator (inventory.rs,inventory/features.rs).hid:tempfileadded as a dev-dependency for the persistence tests (already in the workspace lockfile; thegpui/gpui-componentpins are untouched).agent-core: the agent's inventory watcher composespersisted()withwith_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 inload().