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            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        // A bridge state commit briefly holds this same lock. Retry next refresh
337        // rather than interrupting healthy work for ordinary lock contention.
338        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            // Delegations belong to the connection handler, which adds them.
352            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                // Persist disabled and tool-free first, so failed deletion can
392                // neither resurrect a live account nor hand a later login the grant.
393                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
415/// The longest tool call an owner session in `workspace` may make, from the
416/// configuration its sessions load. Read at each (re)start of the component.
417fn 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
427/// Account transactions fail fast while their lock is held, and a running
428/// bridge holds it for every state commit. Operator commands retry through
429/// that contention instead of failing whenever they coincide with a commit.
430async 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
448/// Only the authenticated account owner may receive tools. Credentials without
449/// a known owner ID grant tools to nobody, even when the setting asks for it.
450fn 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}