use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use crate::daemon::reconciler::request::ReconcileRequest;
use crate::daemon::reconciler::AgentTarget;
use crate::daemon::SharedBindings;
use crate::hooks::decided_agents::Decided;
use crate::hooks::DetectedAgent;
pub const TICK: Duration = Duration::from_secs(60);
const RETRY_AFTER: Duration = Duration::from_secs(600);
pub(crate) fn arrivals(detected: Vec<DetectedAgent>, decided: &Decided) -> Vec<DetectedAgent> {
detected
.into_iter()
.filter(|a| a.installable() && !decided.contains(a.agent_type()))
.collect()
}
pub(crate) type Installer = Box<dyn Fn(&DetectedAgent) -> Result<(), crate::error::OlError> + Send>;
pub(crate) fn hook_installer(openlatch_dir: PathBuf, port: u16, token: String) -> Installer {
Box::new(move |agent| {
crate::hooks::staging::stage_hook_binary(&openlatch_dir)?;
crate::hooks::install_hooks(&*agent.binding, port, &token).map(|_| ())
})
}
pub(crate) struct ArrivalWiring {
openlatch_dir: PathBuf,
install: Installer,
bindings: SharedBindings,
reconcile_tx: mpsc::Sender<ReconcileRequest>,
watch: Option<ArmWatcher>,
watchers: Vec<WatchGuard>,
retry_at: HashMap<&'static str, Instant>,
}
pub(crate) type WatchGuard = Box<dyn std::any::Any + Send>;
type ArmWatcher = fn(&[PathBuf], mpsc::Sender<ReconcileRequest>) -> Option<WatchGuard>;
pub(crate) fn arm_watcher(
paths: &[PathBuf],
tx: mpsc::Sender<ReconcileRequest>,
) -> Option<WatchGuard> {
match crate::daemon::watcher::spawn_watcher(paths, tx) {
Ok(w) => Some(Box::new(w)),
Err(e) => {
tracing::warn!(error = %e, "filesystem watcher failed — falling back to poll-only");
None
}
}
}
impl ArrivalWiring {
pub(crate) fn new(
openlatch_dir: PathBuf,
install: Installer,
bindings: SharedBindings,
reconcile_tx: mpsc::Sender<ReconcileRequest>,
) -> Self {
Self {
openlatch_dir,
install,
bindings,
reconcile_tx,
watch: Some(arm_watcher),
watchers: Vec::new(),
retry_at: HashMap::new(),
}
}
pub(crate) fn pass(&mut self, detected: Vec<DetectedAgent>, now: Instant) -> Vec<&'static str> {
let decided = match crate::hooks::decided_agents::load(&self.openlatch_dir) {
Ok(Some(decided)) => decided,
Ok(None) => {
if let Err(e) = crate::hooks::decided_agents::record(
&self.openlatch_dir,
detected.iter().map(DetectedAgent::agent_type),
) {
tracing::warn!(error = %e.message, "could not seed the decided-agents record");
}
return Vec::new();
}
Err(e) => {
tracing::warn!(error = %e.message, "decided-agents record unreadable — no agent is wired late");
return Vec::new();
}
};
let mut wired = Vec::new();
for agent in arrivals(detected, &decided) {
let kind = agent.agent_type();
if self.retry_at.get(kind).is_some_and(|at| now < *at) {
continue;
}
if let Err(e) = (self.install)(&agent) {
tracing::warn!(
agent = kind,
code = e.code,
error = %e.message,
"a newly installed agent could not be wired — retrying later"
);
self.retry_at.insert(kind, now + RETRY_AFTER);
continue;
}
self.retry_at.remove(kind);
if let Err(e) = crate::hooks::decided_agents::record(&self.openlatch_dir, [kind]) {
tracing::warn!(agent = kind, error = %e.message, "wired, but not recorded as decided");
}
tracing::info!(
agent = kind,
hooks = %agent.settings_path().display(),
"wired an agent installed after the daemon started"
);
let target = AgentTarget::for_agent(&agent);
if let Some(watch) = self.watch {
let mut paths = vec![target.settings_path.clone()];
paths.extend(target.binding.tamper_watch_paths());
self.watchers
.extend(watch(&paths, self.reconcile_tx.clone()));
}
if self
.reconcile_tx
.blocking_send(ReconcileRequest::AddTarget(target))
.is_err()
{
tracing::warn!(
agent = kind,
"reconciler gone — the new agent is not watched"
);
}
self.bindings.add(agent);
wired.push(kind);
}
wired
}
}
pub(crate) async fn run(
state: Arc<Mutex<ArrivalWiring>>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let mut tick = tokio::time::interval(TICK);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = tick.tick() => {}
_ = shutdown.wait_for(|stop| *stop) => return,
}
let uninstalled = crate::config::Config::load(None, None, false)
.map(|c| c.supervision.marked_uninstalled())
.unwrap_or(true);
if uninstalled {
continue;
}
let state = state.clone();
let _ = tokio::task::spawn_blocking(move || {
let detected = crate::hooks::detect_agents();
let mut guard = match state.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.pass(detected, Instant::now());
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::binding::test_support::FakeBinding;
fn agent(kind: &'static str, installable: bool, dir: &std::path::Path) -> DetectedAgent {
DetectedAgent {
kind: crate::hooks::AgentKind::ClaudeCode,
binding: Arc::new(FakeBinding {
agent_type: kind,
installable,
config_dir: dir.join(kind),
..FakeBinding::default()
}),
}
}
struct Harness {
wiring: ArrivalWiring,
installed: Arc<Mutex<Vec<&'static str>>>,
rx: mpsc::Receiver<ReconcileRequest>,
bindings: SharedBindings,
_dir: tempfile::TempDir,
}
fn harness(fail: &'static [&'static str]) -> Harness {
let dir = tempfile::tempdir().expect("temp dir");
let installed = Arc::new(Mutex::new(Vec::new()));
let seen = installed.clone();
let install: Installer = Box::new(move |a: &DetectedAgent| {
if fail.contains(&a.agent_type()) {
return Err(crate::error::OlError::new("OL-1404", "refused"));
}
seen.lock().expect("lock").push(a.agent_type());
Ok(())
});
let (tx, rx) = mpsc::channel(8);
let bindings = SharedBindings::default();
let mut wiring =
ArrivalWiring::new(dir.path().to_path_buf(), install, bindings.clone(), tx);
wiring.watch = None;
Harness {
wiring,
installed,
rx,
bindings,
_dir: dir,
}
}
#[test]
fn a_host_without_a_record_is_seeded_and_nothing_is_wired() {
let mut h = harness(&[]);
let dir = h.wiring.openlatch_dir.clone();
let wired = h
.wiring
.pass(vec![agent("claude-code", true, &dir)], Instant::now());
assert!(wired.is_empty());
assert!(h.installed.lock().expect("lock").is_empty());
let decided = crate::hooks::decided_agents::load(&dir)
.expect("load")
.expect("seeded");
assert!(decided.contains("claude-code"));
}
#[test]
fn only_an_undecided_installable_agent_is_wired() {
let mut h = harness(&[]);
let dir = h.wiring.openlatch_dir.clone();
crate::hooks::decided_agents::record(&dir, ["claude-code", "cursor"]).expect("record");
let detected = vec![
agent("claude-code", true, &dir),
agent("cursor", true, &dir),
agent("codex-cli", true, &dir),
agent("cline", false, &dir),
];
let wired = h.wiring.pass(detected, Instant::now());
assert_eq!(wired, ["codex-cli"]);
assert_eq!(*h.installed.lock().expect("lock"), ["codex-cli"]);
let decided = crate::hooks::decided_agents::load(&dir)
.expect("load")
.expect("present");
assert!(decided.contains("codex-cli"));
assert!(
!decided.contains("cline"),
"a non-installable agent is not decided"
);
match h.rx.try_recv() {
Ok(ReconcileRequest::AddTarget(t)) => assert_eq!(t.binding.agent_type(), "codex-cli"),
other => panic!("expected AddTarget, got {other:?}"),
}
let types: Vec<_> = h
.bindings
.snapshot()
.iter()
.map(DetectedAgent::agent_type)
.collect();
assert_eq!(types, ["codex-cli"]);
let again = h
.wiring
.pass(vec![agent("codex-cli", true, &dir)], Instant::now());
assert!(again.is_empty());
}
#[test]
fn a_failed_install_is_not_recorded_and_retries_only_after_the_backoff() {
let mut h = harness(&["codex-cli"]);
let dir = h.wiring.openlatch_dir.clone();
crate::hooks::decided_agents::record(&dir, ["claude-code"]).expect("record");
let start = Instant::now();
assert!(h
.wiring
.pass(vec![agent("codex-cli", true, &dir)], start)
.is_empty());
let decided = crate::hooks::decided_agents::load(&dir)
.expect("load")
.expect("present");
assert!(!decided.contains("codex-cli"));
assert!(h
.wiring
.retry_at
.get("codex-cli")
.is_some_and(|at| *at > start + TICK));
assert!(
h.rx.try_recv().is_err(),
"nothing to watch for an unwired agent"
);
}
#[test]
fn an_unreadable_record_wires_nothing() {
let mut h = harness(&[]);
let dir = h.wiring.openlatch_dir.clone();
std::fs::write(crate::hooks::decided_agents::record_path(&dir), "{").expect("seed");
assert!(h
.wiring
.pass(vec![agent("codex-cli", true, &dir)], Instant::now())
.is_empty());
assert!(h.installed.lock().expect("lock").is_empty());
}
}