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 delegations: Default::default(),
244 }
245 }
246
247 pub async fn reconcile(&mut self) -> Result<()> {
248 let names = match state::account_names() {
249 Ok(names) => names,
250 Err(_) => {
251 self.supervisor.shutdown().await;
252 self.desired.clear();
253 self.inactive.clear();
254 let mut health = initial_health("discovery", None, false);
255 health.id = "clawbot:discovery-error".into();
256 health.state = ComponentState::Failed;
257 health.error = Some(
258 "Account discovery failed; components stopped until configuration is readable"
259 .into(),
260 );
261 self.inactive.insert("discovery-error".into(), health);
262 bail!("Account discovery failed");
263 }
264 };
265 for name in self
266 .desired
267 .keys()
268 .chain(self.inactive.keys())
269 .cloned()
270 .collect::<Vec<_>>()
271 {
272 if !names.contains(&name) {
273 self.supervisor.stop(&format!("clawbot:{name}")).await;
274 self.desired.remove(&name);
275 self.inactive.remove(&name);
276 }
277 }
278 for name in names {
279 let loaded = (|| -> Result<_> {
280 let (account, settings) = state::account_snapshot(&name)?;
281 Ok((
282 account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
283 settings,
284 ))
285 })();
286 let (credentials, settings) = match loaded {
287 Ok(value) => value,
288 Err(error) => {
289 self.account_error(name, error).await;
290 continue;
291 }
292 };
293 if self.desired.get(&name) == Some(&(credentials.clone(), settings.clone())) {
294 continue;
295 }
296 self.supervisor.stop(&format!("clawbot:{name}")).await;
297 self.inactive.remove(&name);
298 let mut health = initial_health(&name, Some(&credentials), settings.enabled);
299 let tool_owner = tool_owner(&credentials, &settings);
300 if tool_owner.is_some() {
301 health.remote_tools = RemoteTools::Owner;
302 }
303 if settings.enabled {
304 let workspace = settings
305 .workspace
306 .clone()
307 .unwrap_or_else(|| self.workspace.clone());
308 if !workspace.is_absolute() || !workspace.is_dir() {
309 health.state = ComponentState::Failed;
310 health.error =
311 Some("Component workspace must be an existing absolute directory".into());
312 self.inactive.insert(name.clone(), health);
313 self.desired.remove(&name);
314 continue;
315 }
316 self.supervisor.start(
317 Arc::new(ClawBot {
318 account: name.clone(),
319 credentials: credentials.clone(),
320 workspace,
321 socket: self.socket.clone(),
322 tool_owner,
323 }),
324 health,
325 );
326 } else {
327 health.state = ComponentState::Disabled;
328 self.inactive.insert(name.clone(), health);
329 }
330 self.desired.insert(name, (credentials, settings));
331 }
332 Ok(())
333 }
334
335 async fn account_error(&mut self, name: String, error: anyhow::Error) {
336 if is_busy(&error) {
339 return;
340 }
341 self.supervisor.stop(&format!("clawbot:{name}")).await;
342 self.desired.remove(&name);
343 let mut health = initial_health(&name, None, true);
344 health.state = ComponentState::Failed;
345 health.error = Some("Invalid or inaccessible account/settings".into());
346 self.inactive.insert(name, health);
347 }
348
349 pub async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
350 match command {
351 DaemonCommand::Status
353 | DaemonCommand::Delegations { .. }
354 | DaemonCommand::DelegationKill { .. } => return Ok(self.status()),
355 DaemonCommand::Reload => {}
356 DaemonCommand::ClawbotSet {
357 account,
358 enabled,
359 workspace,
360 remote_tools,
361 } => {
362 state::validate_name(&account)?;
363 let workspace = match workspace {
364 Some(path) => {
365 let path = PathBuf::from(path);
366 if !path.is_absolute() || !path.is_dir() {
367 bail!("Invalid component workspace");
368 }
369 Some(std::fs::canonicalize(path)?)
370 }
371 None => None,
372 };
373 retry_while_busy(|| {
374 if state::account(&account)?.is_none() {
375 bail!("Account is not logged in");
376 }
377 let mut settings = state::settings(&account)?;
378 settings.enabled = enabled;
379 if let Some(path) = &workspace {
380 settings.workspace = Some(path.clone());
381 }
382 if let Some(mode) = remote_tools {
383 settings.remote_tools = mode;
384 }
385 state::save_settings(&account, &settings)
386 })
387 .await?;
388 }
389 DaemonCommand::ClawbotLogout { account } => {
390 state::validate_name(&account)?;
391 retry_while_busy(|| {
394 let mut settings = state::settings(&account)?;
395 settings.enabled = false;
396 settings.remote_tools = RemoteTools::None;
397 state::save_settings(&account, &settings)
398 })
399 .await?;
400 self.supervisor.stop(&format!("clawbot:{account}")).await;
401 self.desired.remove(&account);
402 self.inactive.remove(&account);
403 retry_while_busy(|| state::remove(&account)).await?;
404 }
405 }
406 self.reconcile().await?;
407 Ok(self.status())
408 }
409
410 pub async fn shutdown(&mut self) {
411 self.supervisor.shutdown().await;
412 }
413}
414
415fn max_tool_timeout(workspace: &std::path::Path) -> std::time::Duration {
418 let seconds = crate::Config::load(workspace, crate::ConfigOverrides::default())
419 .map(|config| config.tools.max_timeout_seconds)
420 .unwrap_or_else(|error| {
421 tracing::warn!("ClawBot uses the default tool timeout ceiling: {error:#}");
422 crate::config::ToolConfig::default().max_timeout_seconds
423 });
424 std::time::Duration::from_secs(seconds)
425}
426
427async fn retry_while_busy<T>(mut operation: impl FnMut() -> Result<T>) -> Result<T> {
431 let deadline = tokio::time::Instant::now() + BUSY_RETRY;
432 loop {
433 match operation() {
434 Err(error) if is_busy(&error) && tokio::time::Instant::now() < deadline => {
435 tokio::time::sleep(Duration::from_millis(10)).await;
436 }
437 result => return result,
438 }
439 }
440}
441
442fn is_busy(error: &anyhow::Error) -> bool {
443 error
444 .downcast_ref::<std::io::Error>()
445 .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
446}
447
448fn tool_owner(credentials: &Account, settings: &AccountSettings) -> Option<String> {
451 (settings.remote_tools == RemoteTools::Owner)
452 .then(|| credentials.user_id.clone())
453 .flatten()
454 .filter(|owner| !owner.is_empty())
455}
456
457fn initial_health(account: &str, credentials: Option<&Account>, enabled: bool) -> ComponentHealth {
458 ComponentHealth {
459 id: format!("clawbot:{account}"),
460 account: account.into(),
461 bot_id: credentials.and_then(|a| a.bot_id.clone()),
462 user_id: credentials.and_then(|a| a.user_id.clone()),
463 enabled,
464 state: ComponentState::Starting,
465 last_success_unix_seconds: None,
466 error: None,
467 restarts: 0,
468 remote_tools: RemoteTools::None,
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use std::sync::atomic::{AtomicUsize, Ordering};
476
477 struct Fake {
478 starts: Arc<AtomicUsize>,
479 stops: Arc<AtomicUsize>,
480 fail_first: bool,
481 }
482 #[async_trait]
483 impl Component for Fake {
484 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
485 let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
486 if self.fail_first && attempt == 0 {
487 bail!("secret error must never enter status");
488 }
489 health.contact(true);
490 cancellation.cancelled().await;
491 self.stops.fetch_add(1, Ordering::SeqCst);
492 Ok(())
493 }
494 }
495
496 #[tokio::test]
497 async fn starts_once_recovers_reports_contact_and_joins_before_restoration() {
498 let starts = Arc::new(AtomicUsize::new(0));
499 let stops = Arc::new(AtomicUsize::new(0));
500 let fake = Arc::new(Fake {
501 starts: starts.clone(),
502 stops: stops.clone(),
503 fail_first: true,
504 });
505 let mut supervisor = Supervisor {
506 initial_backoff: Duration::from_millis(10),
507 ..Supervisor::default()
508 };
509 supervisor.start(fake.clone(), initial_health("test", None, true));
510 supervisor.start(fake.clone(), initial_health("test", None, true));
511 tokio::time::timeout(Duration::from_secs(2), async {
512 loop {
513 if supervisor.health()[0].state == ComponentState::Connected {
514 break;
515 }
516 tokio::time::sleep(Duration::from_millis(1)).await;
517 }
518 })
519 .await
520 .unwrap();
521 let health = &supervisor.health()[0];
522 assert_eq!(starts.load(Ordering::SeqCst), 2);
523 assert_eq!(health.restarts, 1);
524 assert!(health.last_success_unix_seconds.is_some());
525 assert!(health.error.is_none());
526 supervisor.shutdown().await;
527 assert_eq!(stops.load(Ordering::SeqCst), 1);
528 supervisor.start(fake, initial_health("test", None, true));
529 tokio::time::sleep(Duration::from_millis(20)).await;
530 supervisor.shutdown().await;
531 assert_eq!(starts.load(Ordering::SeqCst), 3);
532 assert_eq!(stops.load(Ordering::SeqCst), 2);
533 }
534
535 #[test]
536 fn credentials_are_not_connection_evidence() {
537 let health = initial_health("saved", None, true);
538 assert_eq!(health.state, ComponentState::Starting);
539 assert_eq!(health.last_success_unix_seconds, None);
540 }
541
542 #[tokio::test]
543 async fn busy_account_snapshot_preserves_live_work_but_invalid_settings_stop_it() {
544 let starts = Arc::new(AtomicUsize::new(0));
545 let stops = Arc::new(AtomicUsize::new(0));
546 let mut components = Components::new(PathBuf::from("/unused.sock"), PathBuf::from("/"));
547 components.supervisor.start(
548 Arc::new(Fake {
549 starts: starts.clone(),
550 stops: stops.clone(),
551 fail_first: false,
552 }),
553 initial_health("test", None, true),
554 );
555 tokio::time::timeout(Duration::from_secs(1), async {
556 while starts.load(Ordering::SeqCst) == 0 {
557 tokio::task::yield_now().await;
558 }
559 })
560 .await
561 .unwrap();
562 components
563 .account_error(
564 "test".into(),
565 std::io::Error::from(std::io::ErrorKind::WouldBlock).into(),
566 )
567 .await;
568 assert_eq!(
569 components.status().components[0].state,
570 ComponentState::Connected
571 );
572 assert_eq!(starts.load(Ordering::SeqCst), 1);
573 assert_eq!(stops.load(Ordering::SeqCst), 0);
574 components
575 .account_error("test".into(), anyhow::anyhow!("invalid settings"))
576 .await;
577 assert_eq!(stops.load(Ordering::SeqCst), 1);
578 assert_eq!(
579 components.status().components[0].state,
580 ComponentState::Failed
581 );
582 }
583
584 struct Stubborn;
585 #[async_trait]
586 impl Component for Stubborn {
587 async fn run(&self, _: CancellationToken, _: HealthReporter) -> Result<()> {
588 std::future::pending().await
589 }
590 }
591
592 #[tokio::test]
593 async fn bounded_stop_aborts_uncooperative_component_and_cancels_backoff() {
594 let mut supervisor = Supervisor {
595 grace: Duration::from_millis(20),
596 ..Supervisor::default()
597 };
598 supervisor.start(Arc::new(Stubborn), initial_health("stubborn", None, true));
599 tokio::task::yield_now().await;
600 tokio::time::timeout(Duration::from_secs(1), supervisor.shutdown())
601 .await
602 .unwrap();
603 assert!(supervisor.health().is_empty());
604 let fake = Arc::new(Fake {
605 starts: Arc::new(AtomicUsize::new(0)),
606 stops: Arc::new(AtomicUsize::new(0)),
607 fail_first: true,
608 });
609 supervisor.start(fake, initial_health("backoff", None, true));
610 tokio::time::sleep(Duration::from_millis(10)).await;
611 assert_eq!(supervisor.health()[0].state, ComponentState::Backoff);
612 assert_eq!(
613 supervisor.health()[0].error.as_deref(),
614 Some("Component stopped unexpectedly; retrying")
615 );
616 tokio::time::timeout(Duration::from_millis(100), supervisor.shutdown())
617 .await
618 .unwrap();
619 }
620
621 #[test]
622 fn remote_tools_require_owner_mode_and_known_owner() {
623 let account = |user_id: Option<&str>| Account {
624 token: "token".into(),
625 base_url: "https://example.invalid".into(),
626 bot_id: Some("bot".into()),
627 user_id: user_id.map(Into::into),
628 };
629 let owner = AccountSettings {
630 remote_tools: RemoteTools::Owner,
631 ..Default::default()
632 };
633 assert_eq!(
634 tool_owner(&account(Some("owner@im.wechat")), &owner).as_deref(),
635 Some("owner@im.wechat")
636 );
637 assert_eq!(tool_owner(&account(None), &owner), None);
638 assert_eq!(tool_owner(&account(Some("")), &owner), None);
639 assert_eq!(
640 tool_owner(
641 &account(Some("owner@im.wechat")),
642 &AccountSettings::default()
643 ),
644 None
645 );
646 }
647}