Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

Commit 0383c58

Browse files
RoberdanCopilot
andcommitted
feat(daemon): power guard + network watchdog with session auto-resume
- PowerGuard: caffeinate (macOS) / systemd-inhibit (Linux) when agents active - Network watchdog: monitors connectivity, logs agent impact on drop/recovery - Auto-resume: respawns dead Copilot sessions via --resume=<session-id> - Diagnostics: GET /api/diagnostics/guards for status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4f8aca0 commit 0383c58

6 files changed

Lines changed: 372 additions & 0 deletions

File tree

daemon/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub mod validation;
3434
#[cfg(feature = "voice")]
3535
pub mod voice;
3636
pub mod workspace;
37+
pub mod power_guard;
38+
pub mod network_watchdog;
3739

3840
/// Resolve the dashboard DB path from the DASHBOARD_DB env var, falling back
3941
/// to ~/.claude/data/dashboard.db. Used wherever the daemon needs to open the

daemon/src/network_watchdog.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Background network connectivity monitor.
2+
//! Detects drops/recoveries and logs which agents were affected.
3+
4+
use std::sync::atomic::{AtomicBool, Ordering};
5+
use std::time::Duration;
6+
7+
use r2d2::Pool;
8+
use r2d2_sqlite::SqliteConnectionManager;
9+
10+
static NETWORK_UP: AtomicBool = AtomicBool::new(true);
11+
12+
/// Returns current network status (non-blocking).
13+
pub fn is_network_up() -> bool {
14+
NETWORK_UP.load(Ordering::Relaxed)
15+
}
16+
17+
/// Spawn as a tokio background task at daemon boot.
18+
/// Checks connectivity every 30s and logs agent impact on drop/recovery.
19+
pub async fn run_watchdog(pool: Pool<SqliteConnectionManager>) {
20+
let mut was_up = true;
21+
let mut lost_agents: Vec<String> = Vec::new();
22+
23+
loop {
24+
tokio::time::sleep(Duration::from_secs(30)).await;
25+
let up = check_connectivity().await;
26+
NETWORK_UP.store(up, Ordering::Relaxed);
27+
28+
if was_up && !up {
29+
tracing::warn!("network_watchdog: connectivity lost");
30+
lost_agents = get_active_agents(&pool);
31+
tracing::warn!(
32+
"network_watchdog: {} agents were active: {:?}",
33+
lost_agents.len(),
34+
lost_agents
35+
);
36+
}
37+
38+
if !was_up && up {
39+
tracing::info!("network_watchdog: connectivity restored");
40+
log_recovery_impact(&pool, &mut lost_agents);
41+
}
42+
43+
was_up = up;
44+
}
45+
}
46+
47+
fn log_recovery_impact(
48+
pool: &Pool<SqliteConnectionManager>,
49+
lost_agents: &mut Vec<String>,
50+
) {
51+
if lost_agents.is_empty() {
52+
return;
53+
}
54+
let current = get_active_agents(pool);
55+
let missing: Vec<_> = lost_agents
56+
.iter()
57+
.filter(|a| !current.contains(a))
58+
.cloned()
59+
.collect();
60+
if !missing.is_empty() {
61+
tracing::warn!(
62+
"network_watchdog: {} agents lost during outage: {:?}",
63+
missing.len(),
64+
missing
65+
);
66+
respawn_copilot_sessions(&missing);
67+
}
68+
lost_agents.clear();
69+
}
70+
71+
/// Attempt to resume dead Copilot CLI sessions.
72+
/// Scans ~/.copilot/session-state/ for recent sessions and relaunches them.
73+
fn respawn_copilot_sessions(missing_agents: &[String]) {
74+
let copilot_agents: Vec<_> = missing_agents
75+
.iter()
76+
.filter(|a| a.contains("copilot"))
77+
.collect();
78+
if copilot_agents.is_empty() {
79+
return;
80+
}
81+
82+
// Find recent session IDs from session-state directory
83+
let session_dir = dirs::home_dir()
84+
.map(|h| h.join(".copilot/session-state"))
85+
.unwrap_or_default();
86+
if !session_dir.exists() {
87+
tracing::debug!("network_watchdog: no session-state dir found");
88+
return;
89+
}
90+
91+
// Get most recent sessions (by modification time)
92+
let mut sessions: Vec<_> = match std::fs::read_dir(&session_dir) {
93+
Ok(entries) => entries
94+
.filter_map(Result::ok)
95+
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
96+
.filter_map(|e| {
97+
let mtime = e.metadata().ok()?.modified().ok()?;
98+
Some((e.file_name().to_string_lossy().to_string(), mtime))
99+
})
100+
.collect(),
101+
Err(_) => return,
102+
};
103+
sessions.sort_by(|a, b| b.1.cmp(&a.1));
104+
105+
// Resume up to N most recent sessions (one per lost copilot agent)
106+
let to_resume = sessions.iter().take(copilot_agents.len());
107+
for (session_id, _) in to_resume {
108+
tracing::info!(
109+
"network_watchdog: resuming copilot session {session_id}"
110+
);
111+
let resume_arg = format!("--resume={session_id}");
112+
match std::process::Command::new("copilot")
113+
.args([&resume_arg, "--allow-all-tools"])
114+
.stdout(std::process::Stdio::null())
115+
.stderr(std::process::Stdio::null())
116+
.spawn()
117+
{
118+
Ok(child) => tracing::info!(
119+
"network_watchdog: copilot resumed pid={}", child.id()
120+
),
121+
Err(e) => tracing::warn!(
122+
"network_watchdog: copilot resume failed: {e}"
123+
),
124+
}
125+
}
126+
}
127+
128+
async fn check_connectivity() -> bool {
129+
let client = reqwest::Client::builder()
130+
.timeout(Duration::from_secs(5))
131+
.build()
132+
.unwrap_or_default();
133+
client
134+
.get("https://api.github.com/zen")
135+
.send()
136+
.await
137+
.map(|r| r.status().is_success())
138+
.unwrap_or(false)
139+
}
140+
141+
fn get_active_agents(pool: &Pool<SqliteConnectionManager>) -> Vec<String> {
142+
let conn = match pool.get() {
143+
Ok(c) => c,
144+
Err(e) => {
145+
tracing::warn!("network_watchdog: pool error: {e}");
146+
return Vec::new();
147+
}
148+
};
149+
let mut stmt = match conn.prepare(
150+
"SELECT name FROM ipc_agents \
151+
WHERE last_seen >= datetime('now', '-10 minutes')",
152+
) {
153+
Ok(s) => s,
154+
Err(e) => {
155+
tracing::debug!(
156+
"network_watchdog: ipc_agents query failed (table may not exist): {e}"
157+
);
158+
return Vec::new();
159+
}
160+
};
161+
stmt.query_map([], |row| row.get::<_, String>(0))
162+
.map(|rows| rows.filter_map(Result::ok).collect())
163+
.unwrap_or_default()
164+
}

daemon/src/power_guard.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//! Platform-specific sleep prevention while agents are active.
2+
//! Spawns an OS sleep inhibitor when the first agent registers,
3+
//! kills it when the last deregisters.
4+
5+
use std::process::{Child, Command};
6+
use std::sync::{Mutex, OnceLock};
7+
8+
use serde::Serialize;
9+
10+
static GUARD: OnceLock<Mutex<PowerGuardInner>> = OnceLock::new();
11+
12+
fn guard() -> &'static Mutex<PowerGuardInner> {
13+
GUARD.get_or_init(|| Mutex::new(PowerGuardInner::new()))
14+
}
15+
16+
struct PowerGuardInner {
17+
process: Option<Child>,
18+
agent_count: u32,
19+
}
20+
21+
impl PowerGuardInner {
22+
fn new() -> Self {
23+
Self { process: None, agent_count: 0 }
24+
}
25+
}
26+
27+
impl Drop for PowerGuardInner {
28+
fn drop(&mut self) {
29+
if let Some(mut child) = self.process.take() {
30+
let _ = child.kill();
31+
let _ = child.wait();
32+
tracing::info!("power_guard: inhibitor killed on drop");
33+
}
34+
}
35+
}
36+
37+
#[derive(Debug, Clone, Serialize)]
38+
pub struct PowerGuardStatus {
39+
pub active: bool,
40+
pub agent_count: u32,
41+
pub platform: &'static str,
42+
}
43+
44+
pub struct PowerGuard;
45+
46+
impl PowerGuard {
47+
/// Call when an agent registers.
48+
pub fn acquire() {
49+
let mut g = guard().lock().expect("power_guard lock poisoned");
50+
g.agent_count += 1;
51+
if g.agent_count == 1 && g.process.is_none() {
52+
g.process = spawn_inhibitor();
53+
if g.process.is_some() {
54+
tracing::info!(
55+
"power_guard: sleep inhibitor started (agents active)"
56+
);
57+
}
58+
}
59+
}
60+
61+
/// Call when an agent deregisters.
62+
pub fn release() {
63+
let mut g = guard().lock().expect("power_guard lock poisoned");
64+
g.agent_count = g.agent_count.saturating_sub(1);
65+
if g.agent_count == 0 {
66+
if let Some(mut child) = g.process.take() {
67+
let _ = child.kill();
68+
let _ = child.wait();
69+
tracing::info!(
70+
"power_guard: sleep inhibitor stopped (no agents)"
71+
);
72+
}
73+
}
74+
}
75+
76+
/// Current status for API/diagnostics.
77+
pub fn status() -> PowerGuardStatus {
78+
let g = guard().lock().expect("power_guard lock poisoned");
79+
PowerGuardStatus {
80+
active: g.process.is_some(),
81+
agent_count: g.agent_count,
82+
platform: current_platform(),
83+
}
84+
}
85+
86+
/// Reset internal state for test isolation.
87+
#[cfg(test)]
88+
fn reset() {
89+
let mut g = guard().lock().expect("power_guard lock poisoned");
90+
if let Some(mut child) = g.process.take() {
91+
let _ = child.kill();
92+
let _ = child.wait();
93+
}
94+
g.agent_count = 0;
95+
}
96+
}
97+
98+
fn current_platform() -> &'static str {
99+
#[cfg(target_os = "macos")]
100+
{ "macos" }
101+
#[cfg(target_os = "linux")]
102+
{ "linux" }
103+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
104+
{ "unsupported" }
105+
}
106+
107+
#[cfg(target_os = "macos")]
108+
fn spawn_inhibitor() -> Option<Child> {
109+
Command::new("caffeinate")
110+
.args(["-i", "-d"])
111+
.spawn()
112+
.map_err(|e| tracing::warn!("power_guard: caffeinate failed: {e}"))
113+
.ok()
114+
}
115+
116+
#[cfg(target_os = "linux")]
117+
fn spawn_inhibitor() -> Option<Child> {
118+
Command::new("systemd-inhibit")
119+
.args([
120+
"--what=idle:sleep",
121+
"--who=convergio-daemon",
122+
"--why=Active agents",
123+
"--mode=block",
124+
"sleep",
125+
"infinity",
126+
])
127+
.spawn()
128+
.map_err(|e| {
129+
tracing::warn!("power_guard: systemd-inhibit failed: {e}")
130+
})
131+
.ok()
132+
}
133+
134+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
135+
fn spawn_inhibitor() -> Option<Child> {
136+
tracing::debug!(
137+
"power_guard: no inhibitor available on this platform"
138+
);
139+
None
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn acquire_release_counting() {
148+
PowerGuard::reset();
149+
let s = PowerGuard::status();
150+
assert_eq!(s.agent_count, 0);
151+
assert!(!s.active);
152+
153+
PowerGuard::acquire();
154+
let s = PowerGuard::status();
155+
assert_eq!(s.agent_count, 1);
156+
// On macOS caffeinate should start
157+
#[cfg(target_os = "macos")]
158+
assert!(s.active);
159+
160+
PowerGuard::acquire();
161+
assert_eq!(PowerGuard::status().agent_count, 2);
162+
163+
PowerGuard::release();
164+
assert_eq!(PowerGuard::status().agent_count, 1);
165+
166+
PowerGuard::release();
167+
let s = PowerGuard::status();
168+
assert_eq!(s.agent_count, 0);
169+
assert!(!s.active);
170+
}
171+
172+
#[test]
173+
fn release_saturates_at_zero() {
174+
PowerGuard::reset();
175+
PowerGuard::release();
176+
PowerGuard::release();
177+
assert_eq!(PowerGuard::status().agent_count, 0);
178+
assert!(!PowerGuard::status().active);
179+
}
180+
}

daemon/src/server/api_ipc/handlers_ext.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ pub async fn api_ipc_agents_register(
6666
tracing::debug!("ws agent_registered broadcast (no subscribers): {e}");
6767
}
6868

69+
crate::power_guard::PowerGuard::acquire();
70+
6971
// Push live agent list + session state to brain viz
7072
broadcast_brain_agent_update(&state);
7173
broadcast_brain_session_update(&state);
@@ -85,6 +87,8 @@ pub async fn api_ipc_agents_unregister(
8587
)
8688
.map_err(|e| ApiError::internal(format!("agent unregister failed: {e}")))?;
8789

90+
crate::power_guard::PowerGuard::release();
91+
8892
if let Err(e) = state.ws_tx.send(json!({
8993
"type": "agent_unregistered",
9094
"agent_id": body.agent_id,
@@ -141,6 +145,8 @@ pub async fn api_ipc_agents_deregister(
141145
)
142146
.map_err(|e| ApiError::internal(format!("agent deregister failed: {e}")))?;
143147

148+
crate::power_guard::PowerGuard::release();
149+
144150
if let Err(e) = state.ws_tx.send(json!({
145151
"type": "agent_deregistered",
146152
"name": body.name,

0 commit comments

Comments
 (0)