Skip to main content

scv_server/
components.rs

1//! Server-owned lifecycle for every long-running integration.
2
3use 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);
17/// How long an operator command waits out a bridge's state commit.
18const BUSY_RETRY: Duration = Duration::from_secs(5);
19
20/// Components must observe cancellation and must not detach child tasks.
21/// Return on failure; the supervisor owns retries and bounded shutdown.
22#[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    /// Idempotent start: replacement must first stop and join the old instance.
90    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                // Catch task panics without letting them take down the daemon or skip retries.
110                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            // The runner owns abort/join of its child, so never abort the runner first.
164            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        // A bridge state commit briefly holds this same lock. Retry next refresh
336        // rather than interrupting healthy work for ordinary lock contention.
337        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                // Persist disabled and tool-free first, so failed deletion can
388                // neither resurrect a live account nor hand a later login the grant.
389                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
411/// The longest tool call an owner session in `workspace` may make, from the
412/// configuration its sessions load. Read at each (re)start of the component.
413fn 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
423/// Account transactions fail fast while their lock is held, and a running
424/// bridge holds it for every state commit. Operator commands retry through
425/// that contention instead of failing whenever they coincide with a commit.
426async 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
444/// Only the authenticated account owner may receive tools. Credentials without
445/// a known owner ID grant tools to nobody, even when the setting asks for it.
446fn 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}