Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 71 additions & 31 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! [`DpiCycleState::capabilities`] stays `None` and presets cycle at their raw
//! (still valid) values — exactly the GUI's "window never opened" behaviour.

use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{Arc, RwLock};

Expand Down Expand Up @@ -80,9 +80,12 @@ pub struct Orchestrator {
/// set/route/online state looks identical across the sleep gap, so the
/// next refresh re-applies volatile settings to every online device.
reapply_all_next_refresh: bool,
/// Config keys of devices first sighted last refresh, due one confirming
/// re-apply: the first write can race the device's own boot and be lost.
reapply_followup: HashSet<String>,
/// Config keys of devices first sighted last refresh, mapped to a remaining
/// count of confirming re-applies. The first write can race the device's own
/// boot and be lost — a cold restart leaves the MX Master 3s slow to enumerate,
/// so the volatile write (DPI/SmartShift/wheel/lighting) is retried for a
/// bounded run of inventory ticks until the device finishes booting (#189).
reapply_followup: HashMap<String, u8>,
shared: SharedRuntime,
}

Expand Down Expand Up @@ -120,7 +123,7 @@ impl Orchestrator {
current_app: None,
inventory: InventoryState::Pending,
reapply_all_next_refresh: false,
reapply_followup: HashSet::new(),
reapply_followup: HashMap::new(),
shared,
};
orch.rebuild();
Expand Down Expand Up @@ -465,31 +468,52 @@ fn reapply_targets(prev: &[AgentDevice], next: &[AgentDevice], reapply_all: bool
.collect()
}

/// How many inventory ticks a first-sighted device keeps re-applying its
/// volatile settings after the initial write. A cold restart leaves a Bolt/
/// Unifying mouse slow to enumerate, so the first write (and a single confirm)
/// can both time out against a still-booting device; retrying for ~8s at the 2s
/// cadence lets the write land once it finishes booting. Bounded rather than
/// read-back-confirmed — see the note on [`plan_reapply`].
const VOLATILE_REAPPLY_CONFIRM_RETRIES: u8 = 4;

/// Plan this refresh's volatile-settings writes: the [`reapply_targets`] set
/// plus one confirming re-apply for devices first sighted last refresh, and
/// the follow-up keys to confirm next refresh.
/// plus a bounded run of confirming re-applies for devices first sighted
/// recently, and the follow-up keys (with remaining retry counts) to confirm
/// next refresh. Reconnects (offline→online) re-apply once — the device was
/// already booted, so it needs no boot-race retry.
fn plan_reapply(
prev: &[AgentDevice],
next: &[AgentDevice],
followup: &HashSet<String>,
followup: &HashMap<String, u8>,
reapply_all: bool,
) -> (Vec<usize>, HashSet<String>) {
) -> (Vec<usize>, HashMap<String, u8>) {
let mut targets = reapply_targets(prev, next, reapply_all);
let next_followup = targets
let mut next_followup: HashMap<String, u8> = targets
.iter()
.filter(|&&idx| {
let id = stable_id(&next[idx]);
!prev.iter().any(|p| stable_id(p) == id)
})
.map(|&idx| next[idx].config_key.clone())
.map(|&idx| {
(
next[idx].config_key.clone(),
VOLATILE_REAPPLY_CONFIRM_RETRIES,
)
})
.collect();
Comment on lines +491 to 503

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 next_followup construction may clobber an in-flight retry entry on rapid replug

next_followup is first populated from the first-sighting targets using .collect() on the iterator. If config_key is derived solely from the device's serial/unit_id (i.e., the same key is preserved across a replug to a new Bolt slot), a device that was mid-retry and also appears as a "first sighting" this tick (because its stable_id changed with the new slot) has its existing budget reset to VOLATILE_REAPPLY_CONFIRM_RETRIES rather than inheriting the remaining count from followup. The retry window is restarted in full.

This is harmless in practice (a replug resets enumeration anyway), but worth confirming that config_key round-trips through a slot change as expected, since the followup map and the first-sighting filter key on different things (config_key vs stable_id).

Fix in Codex Fix in Claude Code

for (idx, dev) in next.iter().enumerate() {
if dev.online
&& dev.route.is_some()
&& followup.contains(&dev.config_key)
&& !targets.contains(&idx)
{
targets.push(idx);
if dev.online && dev.route.is_some() && !targets.contains(&idx) {
// ponytail: bounded retry, not read-back-confirmed. The upgrade is
// to confirm the write took (read DPI back via openlogi_hid and
// stop retrying on a match) instead of running out a fixed budget —
// that converges faster and drops the redundant writes on a device
// that accepted the first one.
if let Some(&remaining) = followup.get(&dev.config_key) {
targets.push(idx);
if remaining > 1 {
next_followup.insert(dev.config_key.clone(), remaining - 1);
}
}
}
}
Comment on lines 504 to 518

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 Retry budget silently dropped on system-wake during cold-boot window

When a system wake fires (reapply_all = true) while a device is mid-retry (e.g., followup["dev"] = 2), the device lands in targets via reapply_targets (all online devices are included). Because !targets.contains(&idx) is then false, the followup retry loop never fires, and the existing budget entry is not copied into next_followup — it is silently discarded.

The device gets one reapply_all write, but if that write also times out against a still-booting device, no further retries follow. This narrows the fix's window of effect: a system wake arriving early in the cold-boot retry sequence leaves the device with the same single-shot behaviour that this PR is trying to fix.

Consider preserving the remaining budget when reapply_all targets a device that already has a followup entry, for example by carrying forward min(remaining, VOLATILE_REAPPLY_CONFIRM_RETRIES) into next_followup even when the device is already in targets.

Fix in Codex Fix in Claude Code

(targets, next_followup)
Expand Down Expand Up @@ -518,8 +542,8 @@ fn write_value<T>(lock: &RwLock<T>, value: T, name: &str) {
#[cfg(test)]
mod tests {
use super::{
AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, plan_reapply,
reapply_targets,
AgentDevice, InventoryHealth, Orchestrator, VOLATILE_REAPPLY_CONFIRM_RETRIES,
configured_wheel_mode, plan_reapply, reapply_targets,
};
use openlogi_core::config::{Config, ScrollResolution};
use openlogi_core::device::Capabilities;
Expand Down Expand Up @@ -634,31 +658,47 @@ mod tests {
}

#[test]
fn plan_reapply_confirms_a_first_sighting_once() {
use std::collections::HashSet;
// First sighting: applied now, queued for one confirming re-apply.
let (targets, followup) = plan_reapply(&[], &[dev("a", 1, true)], &HashSet::new(), false);
fn plan_reapply_retries_a_first_sighting_for_a_bounded_run() {
use std::collections::HashMap;
// First sighting: applied now, queued for VOLATILE_REAPPLY_CONFIRM_RETRIES
// confirming re-applies. A cold restart can leave the device still
// booting, so the initial write and a single confirm need a retry run,
// not a one-shot confirm.
let (targets, followup) = plan_reapply(&[], &[dev("a", 1, true)], &HashMap::new(), false);
assert_eq!(targets, vec![0]);
assert_eq!(followup, HashSet::from(["a".to_string()]));
// Next refresh: the confirming apply fires, then the queue drains.
assert_eq!(
followup,
HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)])
);
// Each steady tick after a first sighting re-applies once and decrements
// the remaining retry budget — the device may still be booting.
let prev = [dev("a", 1, true)];
let (targets, followup) = plan_reapply(&prev, &prev, &followup, false);
let followup_in = HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)]);
let (targets, followup) = plan_reapply(&prev, &prev, &followup_in, false);
assert_eq!(targets, vec![0]);
assert_eq!(
followup,
HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES - 1)])
);
// The budget exhausts: a last retry fires but queues no further ones.
let followup_in = HashMap::from([("a".to_string(), 1)]);
let (targets, followup) = plan_reapply(&prev, &prev, &followup_in, false);
assert_eq!(targets, vec![0]);
assert!(followup.is_empty());
// Steady state after that: nothing.
let (targets, _) = plan_reapply(&prev, &prev, &followup, false);
let (targets, _) = plan_reapply(&prev, &prev, &HashMap::new(), false);
assert!(targets.is_empty());
}

#[test]
fn plan_reapply_transitions_are_not_queued_for_confirmation() {
use std::collections::HashSet;
use std::collections::HashMap;
// A wake from device sleep re-applies once — the device was already
// booted, so no confirming write is queued.
let (targets, followup) = plan_reapply(
&[dev("a", 1, false)],
&[dev("a", 1, true)],
&HashSet::new(),
&HashMap::new(),
false,
);
assert_eq!(targets, vec![0]);
Expand All @@ -667,12 +707,12 @@ mod tests {

#[test]
fn plan_reapply_skips_a_followup_that_went_offline() {
use std::collections::HashSet;
use std::collections::HashMap;
let prev = [dev("a", 1, true)];
let (targets, followup) = plan_reapply(
&prev,
&[dev("a", 1, false)],
&HashSet::from(["a".to_string()]),
&HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)]),
false,
);
assert!(targets.is_empty());
Expand Down