1use anyhow::{Result, bail};
4use async_trait::async_trait;
5use scv_clawbot::state::{self, Account, AccountSettings};
6use scv_protocol::{ComponentHealth, ComponentState, DaemonCommand, DaemonStatus, RemoteTools};
7use std::{
8 collections::BTreeMap,
9 path::PathBuf,
10 sync::{Arc, Mutex},
11 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15
16const STOP_GRACE: Duration = Duration::from_secs(5);
17const BUSY_RETRY: Duration = Duration::from_secs(5);
19
20#[async_trait]
23pub trait Component: Send + Sync + 'static {
24 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()>;
25}
26
27#[derive(Clone)]
28pub struct HealthReporter(Arc<Mutex<ComponentHealth>>);
29
30impl HealthReporter {
31 pub fn contact(&self, connected: bool) {
32 let mut health = self.0.lock().unwrap();
33 if matches!(
34 health.state,
35 ComponentState::Stopping | ComponentState::Stopped
36 ) {
37 return;
38 }
39 health.state = if connected {
40 ComponentState::Connected
41 } else {
42 ComponentState::Disconnected
43 };
44 health.error = (!connected).then(|| "Component contact failed".into());
45 if connected {
46 health.last_success_unix_seconds = Some(
47 SystemTime::now()
48 .duration_since(UNIX_EPOCH)
49 .unwrap_or_default()
50 .as_secs(),
51 );
52 }
53 }
54
55 fn transition(&self, state: ComponentState, error: Option<&str>) {
56 let mut health = self.0.lock().unwrap();
57 health.state = state;
58 health.error = error.map(str::to_owned);
59 }
60
61 fn snapshot(&self) -> ComponentHealth {
62 self.0.lock().unwrap().clone()
63 }
64}
65
66pub struct Supervisor {
67 tasks: BTreeMap<String, RunningComponent>,
68 grace: Duration,
69 initial_backoff: Duration,
70}
71
72struct RunningComponent {
73 cancellation: CancellationToken,
74 task: JoinHandle<()>,
75 health: HealthReporter,
76}
77
78impl Default for Supervisor {
79 fn default() -> Self {
80 Self {
81 tasks: BTreeMap::new(),
82 grace: STOP_GRACE,
83 initial_backoff: Duration::from_secs(1),
84 }
85 }
86}
87
88impl Supervisor {
89 pub fn start(&mut self, component: Arc<dyn Component>, health: ComponentHealth) {
91 if self.tasks.contains_key(&health.id) {
92 return;
93 }
94 let id = health.id.clone();
95 let health = HealthReporter(Arc::new(Mutex::new(health)));
96 let cancellation = CancellationToken::new();
97 let cancel = cancellation.clone();
98 let report = health.clone();
99 let initial_backoff = self.initial_backoff;
100 let grace = self.grace;
101 let task = tokio::spawn(async move {
102 let mut delay = initial_backoff;
103 loop {
104 if cancel.is_cancelled() {
105 break;
106 }
107 report.transition(ComponentState::Starting, None);
108 let started = Instant::now();
109 let instance = component.clone();
111 let child_cancel = cancel.clone();
112 let child_report = report.clone();
113 let mut child =
114 tokio::spawn(async move { instance.run(child_cancel, child_report).await });
115 tokio::select! {
116 biased;
117 _ = cancel.cancelled() => {
118 report.transition(ComponentState::Stopping, None);
119 if tokio::time::timeout(grace, &mut child).await.is_err() {
120 child.abort();
121 let _ = child.await;
122 }
123 break;
124 }
125 _ = &mut child => {}
126 }
127 report.transition(
128 ComponentState::Backoff,
129 Some("Component stopped unexpectedly; retrying"),
130 );
131 report.0.lock().unwrap().restarts += 1;
132 if started.elapsed() >= Duration::from_secs(60) {
133 delay = initial_backoff;
134 }
135 tokio::select! {
136 _ = cancel.cancelled() => break,
137 _ = tokio::time::sleep(delay) => {}
138 }
139 delay = (delay * 2).min(Duration::from_secs(60));
140 }
141 report.transition(ComponentState::Stopped, None);
142 });
143 self.tasks.insert(
144 id,
145 RunningComponent {
146 cancellation,
147 task,
148 health,
149 },
150 );
151 }
152
153 pub fn health(&self) -> Vec<ComponentHealth> {
154 self.tasks
155 .values()
156 .map(|task| task.health.snapshot())
157 .collect()
158 }
159
160 pub async fn stop(&mut self, id: &str) {
161 if let Some(running) = self.tasks.get_mut(id) {
162 running.cancellation.cancel();
163 let _ = (&mut running.task).await;
165 }
166 self.tasks.remove(id);
167 }
168
169 pub async fn shutdown(&mut self) {
170 for task in self.tasks.values() {
171 task.cancellation.cancel();
172 }
173 for id in self.tasks.keys().cloned().collect::<Vec<_>>() {
174 self.stop(&id).await;
175 }
176 }
177}
178
179struct ClawBot {
180 account: String,
181 credentials: Account,
182 workspace: PathBuf,
183 socket: PathBuf,
184 tool_owner: Option<String>,
185}
186
187#[async_trait]
188impl Component for ClawBot {
189 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
190 let tool_owner = self.tool_owner.clone().map(|user_id| {
191 let turn_timeout = scv_clawbot::owner_turn_timeout(max_tool_timeout(&self.workspace));
192 tracing::info!(
193 "ClawBot {} owner turns may run up to {} seconds",
194 self.account,
195 turn_timeout.as_secs()
196 );
197 scv_clawbot::ToolOwner {
198 user_id,
199 turn_timeout,
200 }
201 });
202 scv_clawbot::run_supervised(
203 &self.credentials.token,
204 &self.credentials.base_url,
205 &self.account,
206 &self.workspace,
207 &self.socket,
208 tool_owner.as_ref(),
209 cancellation,
210 Arc::new(move |connected| health.contact(connected)),
211 )
212 .await
213 }
214}
215
216pub(crate) struct Components {
217 supervisor: Supervisor,
218 desired: BTreeMap<String, (Account, AccountSettings)>,
219 inactive: BTreeMap<String, ComponentHealth>,
220 socket: PathBuf,
221 workspace: PathBuf,
222}
223
224impl Components {
225 pub fn new(socket: PathBuf, workspace: PathBuf) -> Self {
226 Self {
227 supervisor: Supervisor::default(),
228 desired: BTreeMap::new(),
229 inactive: BTreeMap::new(),
230 socket,
231 workspace,
232 }
233 }
234
235 pub fn status(&self) -> DaemonStatus {
236 let mut components = self.supervisor.health();
237 components.extend(self.inactive.values().cloned());
238 components.sort_by(|a, b| a.id.cmp(&b.id));
239 DaemonStatus {
240 version: env!("CARGO_PKG_VERSION").into(),
241 pid: std::process::id(),
242 components,
243 }
244 }
245
246 pub async fn reconcile(&mut self) -> Result<()> {
247 let names = match state::account_names() {
248 Ok(names) => names,
249 Err(_) => {
250 self.supervisor.shutdown().await;
251 self.desired.clear();
252 self.inactive.clear();
253 let mut health = initial_health("discovery", None, false);
254 health.id = "clawbot:discovery-error".into();
255 health.state = ComponentState::Failed;
256 health.error = Some(
257 "Account discovery failed; components stopped until configuration is readable"
258 .into(),
259 );
260 self.inactive.insert("discovery-error".into(), health);
261 bail!("Account discovery failed");
262 }
263 };
264 for name in self
265 .desired
266 .keys()
267 .chain(self.inactive.keys())
268 .cloned()
269 .collect::<Vec<_>>()
270 {
271 if !names.contains(&name) {
272 self.supervisor.stop(&format!("clawbot:{name}")).await;
273 self.desired.remove(&name);
274 self.inactive.remove(&name);
275 }
276 }
277 for name in names {
278 let loaded = (|| -> Result<_> {
279 let (account, settings) = state::account_snapshot(&name)?;
280 Ok((
281 account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
282 settings,
283 ))
284 })();
285 let (credentials, settings) = match loaded {
286 Ok(value) => value,
287 Err(error) => {
288 self.account_error(name, error).await;
289 continue;
290 }
291 };
292 if self.desired.get(&name) == Some(&(credentials.clone(), settings.clone())) {
293 continue;
294 }
295 self.supervisor.stop(&format!("clawbot:{name}")).await;
296 self.inactive.remove(&name);
297 let mut health = initial_health(&name, Some(&credentials), settings.enabled);
298 let tool_owner = tool_owner(&credentials, &settings);
299 if tool_owner.is_some() {
300 health.remote_tools = RemoteTools::Owner;
301 }
302 if settings.enabled {
303 let workspace = settings
304 .workspace
305 .clone()
306 .unwrap_or_else(|| self.workspace.clone());
307 if !workspace.is_absolute() || !workspace.is_dir() {
308 health.state = ComponentState::Failed;
309 health.error =
310 Some("Component workspace must be an existing absolute directory".into());
311 self.inactive.insert(name.clone(), health);
312 self.desired.remove(&name);
313 continue;
314 }
315 self.supervisor.start(
316 Arc::new(ClawBot {
317 account: name.clone(),
318 credentials: credentials.clone(),
319 workspace,
320 socket: self.socket.clone(),
321 tool_owner,
322 }),
323 health,
324 );
325 } else {
326 health.state = ComponentState::Disabled;
327 self.inactive.insert(name.clone(), health);
328 }
329 self.desired.insert(name, (credentials, settings));
330 }
331 Ok(())
332 }
333
334 async fn account_error(&mut self, name: String, error: anyhow::Error) {
335 if is_busy(&error) {
338 return;
339 }
340 self.supervisor.stop(&format!("clawbot:{name}")).await;
341 self.desired.remove(&name);
342 let mut health = initial_health(&name, None, true);
343 health.state = ComponentState::Failed;
344 health.error = Some("Invalid or inaccessible account/settings".into());
345 self.inactive.insert(name, health);
346 }
347
348 pub async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
349 match command {
350 DaemonCommand::Status => return Ok(self.status()),
351 DaemonCommand::Reload => {}
352 DaemonCommand::ClawbotSet {
353 account,
354 enabled,
355 workspace,
356 remote_tools,
357 } => {
358 state::validate_name(&account)?;
359 let workspace = match workspace {
360 Some(path) => {
361 let path = PathBuf::from(path);
362 if !path.is_absolute() || !path.is_dir() {
363 bail!("Invalid component workspace");
364 }
365 Some(std::fs::canonicalize(path)?)
366 }
367 None => None,
368 };
369 retry_while_busy(|| {
370 if state::account(&account)?.is_none() {
371 bail!("Account is not logged in");
372 }
373 let mut settings = state::settings(&account)?;
374 settings.enabled = enabled;
375 if let Some(path) = &workspace {
376 settings.workspace = Some(path.clone());
377 }
378 if let Some(mode) = remote_tools {
379 settings.remote_tools = mode;
380 }
381 state::save_settings(&account, &settings)
382 })
383 .await?;
384 }
385 DaemonCommand::ClawbotLogout { account } => {
386 state::validate_name(&account)?;
387 retry_while_busy(|| {
390 let mut settings = state::settings(&account)?;
391 settings.enabled = false;
392 settings.remote_tools = RemoteTools::None;
393 state::save_settings(&account, &settings)
394 })
395 .await?;
396 self.supervisor.stop(&format!("clawbot:{account}")).await;
397 self.desired.remove(&account);
398 self.inactive.remove(&account);
399 retry_while_busy(|| state::remove(&account)).await?;
400 }
401 }
402 self.reconcile().await?;
403 Ok(self.status())
404 }
405
406 pub async fn shutdown(&mut self) {
407 self.supervisor.shutdown().await;
408 }
409}
410
411fn max_tool_timeout(workspace: &std::path::Path) -> std::time::Duration {
414 let seconds = crate::Config::load(workspace, crate::ConfigOverrides::default())
415 .map(|config| config.tools.max_timeout_seconds)
416 .unwrap_or_else(|error| {
417 tracing::warn!("ClawBot uses the default tool timeout ceiling: {error:#}");
418 crate::config::ToolConfig::default().max_timeout_seconds
419 });
420 std::time::Duration::from_secs(seconds)
421}
422
423async fn retry_while_busy<T>(mut operation: impl FnMut() -> Result<T>) -> Result<T> {
427 let deadline = tokio::time::Instant::now() + BUSY_RETRY;
428 loop {
429 match operation() {
430 Err(error) if is_busy(&error) && tokio::time::Instant::now() < deadline => {
431 tokio::time::sleep(Duration::from_millis(10)).await;
432 }
433 result => return result,
434 }
435 }
436}
437
438fn is_busy(error: &anyhow::Error) -> bool {
439 error
440 .downcast_ref::<std::io::Error>()
441 .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
442}
443
444fn tool_owner(credentials: &Account, settings: &AccountSettings) -> Option<String> {
447 (settings.remote_tools == RemoteTools::Owner)
448 .then(|| credentials.user_id.clone())
449 .flatten()
450 .filter(|owner| !owner.is_empty())
451}
452
453fn initial_health(account: &str, credentials: Option<&Account>, enabled: bool) -> ComponentHealth {
454 ComponentHealth {
455 id: format!("clawbot:{account}"),
456 account: account.into(),
457 bot_id: credentials.and_then(|a| a.bot_id.clone()),
458 user_id: credentials.and_then(|a| a.user_id.clone()),
459 enabled,
460 state: ComponentState::Starting,
461 last_success_unix_seconds: None,
462 error: None,
463 restarts: 0,
464 remote_tools: RemoteTools::None,
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::sync::atomic::{AtomicUsize, Ordering};
472
473 struct Fake {
474 starts: Arc<AtomicUsize>,
475 stops: Arc<AtomicUsize>,
476 fail_first: bool,
477 }
478 #[async_trait]
479 impl Component for Fake {
480 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
481 let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
482 if self.fail_first && attempt == 0 {
483 bail!("secret error must never enter status");
484 }
485 health.contact(true);
486 cancellation.cancelled().await;
487 self.stops.fetch_add(1, Ordering::SeqCst);
488 Ok(())
489 }
490 }
491
492 #[tokio::test]
493 async fn starts_once_recovers_reports_contact_and_joins_before_restoration() {
494 let starts = Arc::new(AtomicUsize::new(0));
495 let stops = Arc::new(AtomicUsize::new(0));
496 let fake = Arc::new(Fake {
497 starts: starts.clone(),
498 stops: stops.clone(),
499 fail_first: true,
500 });
501 let mut supervisor = Supervisor {
502 initial_backoff: Duration::from_millis(10),
503 ..Supervisor::default()
504 };
505 supervisor.start(fake.clone(), initial_health("test", None, true));
506 supervisor.start(fake.clone(), initial_health("test", None, true));
507 tokio::time::timeout(Duration::from_secs(2), async {
508 loop {
509 if supervisor.health()[0].state == ComponentState::Connected {
510 break;
511 }
512 tokio::time::sleep(Duration::from_millis(1)).await;
513 }
514 })
515 .await
516 .unwrap();
517 let health = &supervisor.health()[0];
518 assert_eq!(starts.load(Ordering::SeqCst), 2);
519 assert_eq!(health.restarts, 1);
520 assert!(health.last_success_unix_seconds.is_some());
521 assert!(health.error.is_none());
522 supervisor.shutdown().await;
523 assert_eq!(stops.load(Ordering::SeqCst), 1);
524 supervisor.start(fake, initial_health("test", None, true));
525 tokio::time::sleep(Duration::from_millis(20)).await;
526 supervisor.shutdown().await;
527 assert_eq!(starts.load(Ordering::SeqCst), 3);
528 assert_eq!(stops.load(Ordering::SeqCst), 2);
529 }
530
531 #[test]
532 fn credentials_are_not_connection_evidence() {
533 let health = initial_health("saved", None, true);
534 assert_eq!(health.state, ComponentState::Starting);
535 assert_eq!(health.last_success_unix_seconds, None);
536 }
537
538 #[tokio::test]
539 async fn busy_account_snapshot_preserves_live_work_but_invalid_settings_stop_it() {
540 let starts = Arc::new(AtomicUsize::new(0));
541 let stops = Arc::new(AtomicUsize::new(0));
542 let mut components = Components::new(PathBuf::from("/unused.sock"), PathBuf::from("/"));
543 components.supervisor.start(
544 Arc::new(Fake {
545 starts: starts.clone(),
546 stops: stops.clone(),
547 fail_first: false,
548 }),
549 initial_health("test", None, true),
550 );
551 tokio::time::timeout(Duration::from_secs(1), async {
552 while starts.load(Ordering::SeqCst) == 0 {
553 tokio::task::yield_now().await;
554 }
555 })
556 .await
557 .unwrap();
558 components
559 .account_error(
560 "test".into(),
561 std::io::Error::from(std::io::ErrorKind::WouldBlock).into(),
562 )
563 .await;
564 assert_eq!(
565 components.status().components[0].state,
566 ComponentState::Connected
567 );
568 assert_eq!(starts.load(Ordering::SeqCst), 1);
569 assert_eq!(stops.load(Ordering::SeqCst), 0);
570 components
571 .account_error("test".into(), anyhow::anyhow!("invalid settings"))
572 .await;
573 assert_eq!(stops.load(Ordering::SeqCst), 1);
574 assert_eq!(
575 components.status().components[0].state,
576 ComponentState::Failed
577 );
578 }
579
580 struct Stubborn;
581 #[async_trait]
582 impl Component for Stubborn {
583 async fn run(&self, _: CancellationToken, _: HealthReporter) -> Result<()> {
584 std::future::pending().await
585 }
586 }
587
588 #[tokio::test]
589 async fn bounded_stop_aborts_uncooperative_component_and_cancels_backoff() {
590 let mut supervisor = Supervisor {
591 grace: Duration::from_millis(20),
592 ..Supervisor::default()
593 };
594 supervisor.start(Arc::new(Stubborn), initial_health("stubborn", None, true));
595 tokio::task::yield_now().await;
596 tokio::time::timeout(Duration::from_secs(1), supervisor.shutdown())
597 .await
598 .unwrap();
599 assert!(supervisor.health().is_empty());
600 let fake = Arc::new(Fake {
601 starts: Arc::new(AtomicUsize::new(0)),
602 stops: Arc::new(AtomicUsize::new(0)),
603 fail_first: true,
604 });
605 supervisor.start(fake, initial_health("backoff", None, true));
606 tokio::time::sleep(Duration::from_millis(10)).await;
607 assert_eq!(supervisor.health()[0].state, ComponentState::Backoff);
608 assert_eq!(
609 supervisor.health()[0].error.as_deref(),
610 Some("Component stopped unexpectedly; retrying")
611 );
612 tokio::time::timeout(Duration::from_millis(100), supervisor.shutdown())
613 .await
614 .unwrap();
615 }
616
617 #[test]
618 fn remote_tools_require_owner_mode_and_known_owner() {
619 let account = |user_id: Option<&str>| Account {
620 token: "token".into(),
621 base_url: "https://example.invalid".into(),
622 bot_id: Some("bot".into()),
623 user_id: user_id.map(Into::into),
624 };
625 let owner = AccountSettings {
626 remote_tools: RemoteTools::Owner,
627 ..Default::default()
628 };
629 assert_eq!(
630 tool_owner(&account(Some("owner@im.wechat")), &owner).as_deref(),
631 Some("owner@im.wechat")
632 );
633 assert_eq!(tool_owner(&account(None), &owner), None);
634 assert_eq!(tool_owner(&account(Some("")), &owner), None);
635 assert_eq!(
636 tool_owner(
637 &account(Some("owner@im.wechat")),
638 &AccountSettings::default()
639 ),
640 None
641 );
642 }
643}