1use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use futures::stream::BoxStream;
14use serde::{Deserialize, Serialize};
15use tokio::io::AsyncBufReadExt;
16use tokio::sync::{Mutex as AsyncMutex, RwLock, watch};
17
18use crate::bus::{EventBus, InProcessBus};
19use crate::caliband::client::CalibandClient;
20use crate::caliband::stream::{NormalizeOptions, Normalized, normalize_frame};
21use crate::caliband::wire::{self, AgentRecord, AttachInbound, Endpoint, SpawnSpec};
22use crate::config_store::{ConfigStore, SqliteConfigStore};
23use crate::discovery::{DiscoveryEnv, EnsureConfig, ensure_caliband};
24use crate::error::{CoreError, Result};
25use crate::event::{EventKind, FleetEvent};
26use crate::metrics::{Metrics, MetricsSnapshot};
27use crate::model::{
28 Agent, AgentId, AgentStatus, FleetChange, FleetSnapshot, PermissionPosture, Workspace,
29 WorkspaceHealth,
30};
31use crate::ownership::{Ownership, SelfOwnsAll};
32use crate::registry::Registry;
33use crate::store::Store;
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct SpawnRequest {
40 pub prompt: String,
42 pub label: Option<String>,
44 pub model: Option<String>,
46 pub isolation_worktree: bool,
49 pub tool_allowlist: Option<Vec<String>>,
51 pub interactive: bool,
54 pub frontmatter_path: Option<PathBuf>,
57 pub timeout_secs: Option<u64>,
63 pub provider_ref: Option<String>,
68 pub permission_posture: PermissionPosture,
74}
75
76impl SpawnRequest {
77 pub fn new(prompt: impl Into<String>) -> Self {
79 Self {
80 prompt: prompt.into(),
81 label: None,
82 model: None,
83 isolation_worktree: true,
84 tool_allowlist: None,
85 interactive: false,
86 frontmatter_path: None,
87 provider_ref: None,
88 permission_posture: PermissionPosture::Supervised,
89 timeout_secs: None,
90 }
91 }
92
93 fn into_spec(self) -> SpawnSpec {
94 SpawnSpec {
95 label: self.label,
96 frontmatter_path: self.frontmatter_path,
97 initial_prompt: self.prompt,
98 model: self.model,
99 provider: None,
102 tool_allowlist: self.tool_allowlist,
103 isolation_worktree: self.isolation_worktree,
104 inherit_hooks: true,
105 interactive: self.interactive,
106 permission_posture: wire::wire_posture(self.permission_posture),
107 inherited_hooks_config: None,
113 source: None,
114 resume_session: None,
115 drive_protocol: wire::DriveProtocol::Ndjson,
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct FleetConfig {
123 pub host: String,
125 pub data_dir: PathBuf,
127 pub poll_interval: Duration,
129 pub discovery_env: DiscoveryEnv,
131 pub ensure: EnsureConfig,
133 pub normalize: NormalizeOptions,
135 pub event_buffer: usize,
137 pub default_env: std::collections::BTreeMap<String, String>,
139 pub attach_backoff: AttachBackoff,
141 pub caliband_network: Option<CalibandNetworkConfig>,
147}
148
149#[derive(Debug, Clone)]
154pub struct CalibandNetworkConfig {
155 pub addr: String,
157 pub ca_pem: Vec<u8>,
159 pub server_name: String,
161 pub token: Option<String>,
163}
164
165#[derive(Debug, Clone, Copy)]
174pub struct AttachBackoff {
175 pub base: Duration,
177 pub max: Duration,
179 pub max_retries: u32,
181}
182
183impl Default for AttachBackoff {
184 fn default() -> Self {
185 Self {
186 base: Duration::from_millis(200),
187 max: Duration::from_secs(10),
188 max_retries: 8,
189 }
190 }
191}
192
193impl AttachBackoff {
194 fn delay_for(&self, agent_id: &str, attempt: u32) -> Duration {
198 use std::hash::{Hash, Hasher};
199 let exp = self
200 .base
201 .saturating_mul(2u32.saturating_pow(attempt.min(31)));
202 let capped = exp.min(self.max);
203 let mut h = std::collections::hash_map::DefaultHasher::new();
204 agent_id.hash(&mut h);
205 attempt.hash(&mut h);
206 let frac = 0.5 + 0.5 * ((h.finish() % 1000) as f64 / 1000.0);
207 Duration::from_secs_f64(capped.as_secs_f64() * frac)
208 }
209}
210
211impl FleetConfig {
212 pub fn new(host: impl Into<String>, data_dir: impl Into<PathBuf>) -> Self {
214 Self {
215 host: host.into(),
216 data_dir: data_dir.into(),
217 poll_interval: Duration::from_secs(2),
218 discovery_env: DiscoveryEnv::from_process(),
219 ensure: EnsureConfig::default(),
220 normalize: NormalizeOptions::default(),
221 event_buffer: 1024,
222 default_env: std::collections::BTreeMap::new(),
223 attach_backoff: AttachBackoff::default(),
224 caliband_network: None,
225 }
226 }
227
228 fn network_client(&self) -> Result<Option<CalibandClient>> {
233 match &self.caliband_network {
234 None => Ok(None),
235 Some(net) => {
236 let tls =
237 crate::caliband::transport::tls_client_from_pem(&net.ca_pem, &net.server_name)?;
238 Ok(Some(CalibandClient::connect_tcp(
239 net.addr.clone(),
240 Some(tls),
241 net.token.clone(),
242 )))
243 }
244 }
245 }
246}
247
248#[derive(Clone)]
256pub(crate) struct Emitter {
257 store: Arc<dyn Store>,
258 bus: Arc<dyn EventBus>,
259 seqs: Arc<AsyncMutex<HashMap<String, u64>>>,
261 metrics: Arc<Metrics>,
262}
263
264impl Emitter {
265 #[cfg(feature = "k8s")]
272 pub(crate) fn new(bus: Arc<dyn EventBus>, store: Arc<dyn Store>) -> Self {
273 Self {
274 store,
275 bus,
276 seqs: Arc::new(AsyncMutex::new(HashMap::new())),
277 metrics: Arc::new(Metrics::default()),
278 }
279 }
280
281 #[cfg(feature = "k8s")]
284 pub(crate) fn store(&self) -> Arc<dyn Store> {
285 self.store.clone()
286 }
287
288 #[cfg(feature = "k8s")]
291 pub(crate) fn metrics_snapshot(&self, active: u64) -> MetricsSnapshot {
292 self.metrics.snapshot(active)
293 }
294
295 async fn next_event(&self, repo: &str, agent_id: &str, kind: EventKind) -> FleetEvent {
296 let stream_key = crate::event::stream_key_for(repo, agent_id);
297 let seq = self.next_seq(&stream_key).await;
298 FleetEvent {
299 seq,
300 ts: chrono::Utc::now().to_rfc3339(),
301 repo: repo.to_string(),
302 agent_id: agent_id.to_string(),
303 kind,
304 actor: crate::actor::current(),
305 on_behalf_of: crate::actor::current_subject(),
306 }
307 }
308
309 async fn next_seq(&self, stream_key: &str) -> u64 {
313 {
315 let mut seqs = self.seqs.lock().await;
316 if let Some(n) = seqs.get(stream_key) {
317 let next = n + 1;
318 seqs.insert(stream_key.to_string(), next);
319 return next;
320 }
321 }
322 let seeded = self.store.high_water(stream_key).await.unwrap_or_else(|e| {
324 tracing::warn!(
325 target: "prospero_fleet", stream = %stream_key, error = %e,
326 "high_water read failed seeding per-stream seq; starting at 0"
327 );
328 0
329 });
330 let mut seqs = self.seqs.lock().await;
335 let next = match seqs.get(stream_key) {
337 Some(n) => n + 1,
338 None => seeded + 1,
339 };
340 seqs.insert(stream_key.to_string(), next);
341 next
342 }
343
344 pub(crate) async fn emit(&self, repo: &str, agent_id: &str, kind: EventKind) {
350 let mut event = self.next_event(repo, agent_id, kind).await;
351 let append_err = self.append_with_seq_retry(&mut event).await;
352 let lost_seq = event.seq;
353 match &append_err {
354 None => self.metrics.record_append_ok(),
355 Some(_) => self.metrics.record_append_failure(),
356 }
357 self.bus.publish(event);
361 if let Some(e) = append_err {
362 tracing::warn!(target: "prospero_fleet", error = %e, "failed to persist event");
363 self.emit_persist_gap(repo, agent_id, lost_seq, e).await;
364 }
365 }
366
367 async fn append_with_seq_retry(&self, event: &mut FleetEvent) -> Option<CoreError> {
372 const SEQ_CONFLICT_RETRIES: u32 = 8;
373 let stream_key = event.stream_key();
374 let mut attempts = 0;
375 loop {
376 match self.store.append(event).await {
377 Ok(()) => return None,
378 Err(CoreError::SeqConflict) if attempts < SEQ_CONFLICT_RETRIES => {
379 attempts += 1;
380 self.reseed_seq(&stream_key).await;
383 event.seq = self.next_seq(&stream_key).await;
384 }
385 Err(e) => return Some(e),
386 }
387 }
388 }
389
390 async fn reseed_seq(&self, stream_key: &str) {
393 self.seqs.lock().await.remove(stream_key);
394 }
395
396 async fn emit_persist_gap(&self, repo: &str, agent_id: &str, lost_seq: u64, err: CoreError) {
402 let mut marker = self
403 .next_event(
404 repo,
405 agent_id,
406 EventKind::StorePersistFailed {
407 lost_seq,
408 detail: err.to_string(),
409 },
410 )
411 .await;
412 match self.append_with_seq_retry(&mut marker).await {
413 None => self.metrics.record_append_ok(),
414 Some(e) => {
415 self.metrics.record_append_failure();
418 tracing::warn!(target: "prospero_fleet", error = %e, "failed to persist store-gap marker");
419 }
420 }
421 self.bus.publish(marker);
422 }
423}
424
425struct Inner {
426 config: FleetConfig,
427 snapshot: RwLock<FleetSnapshot>,
428 registry: RwLock<Registry>,
429 config_store: Arc<dyn ConfigStore>,
430 clients: Mutex<HashMap<String, CalibandClient>>,
432 attached: Mutex<HashSet<String>>,
434 recent_spawns: Mutex<HashMap<String, String>>,
441 deadlines: Mutex<HashMap<String, String>>,
448 emitter: Emitter,
449 ownership: Arc<dyn Ownership>,
450 shutdown: watch::Sender<bool>,
453}
454
455#[derive(Clone)]
457pub struct FleetManager {
458 inner: Arc<Inner>,
459}
460
461impl FleetManager {
462 pub async fn new(config: FleetConfig, store: Arc<dyn Store>) -> Result<Self> {
467 let config_store = Arc::new(SqliteConfigStore::open(&config.data_dir).await?);
468 Self::with_config_store(config, store, config_store).await
469 }
470
471 pub async fn with_config_store(
474 config: FleetConfig,
475 store: Arc<dyn Store>,
476 config_store: Arc<dyn ConfigStore>,
477 ) -> Result<Self> {
478 let bus: Arc<dyn EventBus> = Arc::new(InProcessBus::new(config.event_buffer));
479 let ownership: Arc<dyn Ownership> = Arc::new(SelfOwnsAll);
480 Self::with_seams(config, store, config_store, bus, ownership).await
481 }
482
483 pub async fn with_seams(
487 config: FleetConfig,
488 store: Arc<dyn Store>,
489 config_store: Arc<dyn ConfigStore>,
490 bus: Arc<dyn EventBus>,
491 ownership: Arc<dyn Ownership>,
492 ) -> Result<Self> {
493 let registry = Registry {
494 workspaces: config_store.list_repos().await?,
495 };
496 let emitter = Emitter {
497 store,
498 bus,
499 seqs: Arc::new(AsyncMutex::new(HashMap::new())),
500 metrics: Arc::new(Metrics::default()),
501 };
502 let snapshot = FleetSnapshot {
503 host: config.host.clone(),
504 workspaces: registry
505 .workspaces
506 .iter()
507 .map(|r| Workspace {
508 name: r.name.clone(),
509 root: r.root.clone(),
510 sources: crate::caliband::sources::discover_sources(&r.root),
511 health: WorkspaceHealth::Healthy,
512 config: r.config.clone(),
513 agents: Vec::new(),
514 })
515 .collect(),
516 };
517 Ok(Self {
518 inner: Arc::new(Inner {
519 config,
520 snapshot: RwLock::new(snapshot),
521 registry: RwLock::new(registry),
522 config_store,
523 clients: Mutex::new(HashMap::new()),
524 attached: Mutex::new(HashSet::new()),
525 recent_spawns: Mutex::new(HashMap::new()),
526 deadlines: Mutex::new(HashMap::new()),
527 emitter,
528 ownership,
529 shutdown: watch::channel(false).0,
530 }),
531 })
532 }
533
534 pub fn begin_shutdown(&self) {
540 self.inner.shutdown.send_replace(true);
541 }
542
543 pub fn subscribe(&self, stream_key: &str) -> crate::bus::BusSubscription {
547 self.inner.emitter.bus.subscribe(stream_key)
548 }
549
550 #[must_use]
554 pub fn store(&self) -> Arc<dyn Store> {
555 self.inner.emitter.store.clone()
556 }
557
558 #[must_use]
560 pub fn bus(&self) -> Arc<dyn EventBus> {
561 self.inner.emitter.bus.clone()
562 }
563
564 fn subscribe_all(&self) -> crate::bus::BusSubscription {
570 self.inner.emitter.bus.subscribe_all()
571 }
572
573 pub async fn snapshot(&self) -> FleetSnapshot {
576 let mut snap = self.inner.snapshot.read().await.clone();
577 let reg = self.inner.registry.read().await;
578 for repo in &mut snap.workspaces {
579 if let Some(r) = reg.get(&repo.name) {
580 repo.config = r.config.clone();
581 }
582 }
583 snap
584 }
585
586 pub fn metrics(&self) -> MetricsSnapshot {
589 let active = self.inner.attached.lock().unwrap().len() as u64;
590 self.inner.emitter.metrics.snapshot(active)
591 }
592
593 pub async fn readiness(&self) -> crate::model::Readiness {
597 let store_writable = self.inner.emitter.store.writable().await;
598 let snap = self.inner.snapshot.read().await;
599 let workspaces_total = snap.workspaces.len();
600 let workspaces_healthy = snap
601 .workspaces
602 .iter()
603 .filter(|r| matches!(r.health, WorkspaceHealth::Healthy))
604 .count();
605 crate::model::Readiness {
606 ready: store_writable,
607 store_writable,
608 workspaces_total,
609 workspaces_healthy,
610 workspaces_unreachable: workspaces_total - workspaces_healthy,
611 }
612 }
613
614 pub async fn history(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>> {
619 self.inner.emitter.store.replay(stream_key, from_seq).await
620 }
621
622 pub async fn prune_older_than(&self, max_age: std::time::Duration) -> Result<u64> {
625 crate::store::prune_store_older_than(self.inner.emitter.store.as_ref(), max_age).await
626 }
627
628 pub async fn add_workspace(
630 &self,
631 name: impl Into<String>,
632 root: impl Into<PathBuf>,
633 ) -> Result<()> {
634 self.add_workspace_with_config(name, root, Default::default())
635 .await
636 }
637
638 pub async fn add_repo(&self, name: impl Into<String>, root: impl Into<PathBuf>) -> Result<()> {
640 self.add_workspace(name, root).await
641 }
642
643 pub async fn add_repo_with_config(
645 &self,
646 name: impl Into<String>,
647 root: impl Into<PathBuf>,
648 config: crate::registry::RepoProviderConfig,
649 ) -> Result<()> {
650 self.add_workspace_with_config(name, root, config).await
651 }
652
653 pub async fn add_workspace_with_config(
655 &self,
656 name: impl Into<String>,
657 root: impl Into<PathBuf>,
658 config: crate::registry::RepoProviderConfig,
659 ) -> Result<()> {
660 let name = name.into();
661 let root = root.into();
662 crate::provider_env::validate_provider_config(&config)
666 .map_err(CoreError::ProviderMisconfigured)?;
667 let root = std::fs::canonicalize(&root).unwrap_or(root);
673 let repo = {
674 let mut reg = self.inner.registry.write().await;
675 reg.add(name.clone(), root.clone())?;
676 reg.set_config(&name, config);
677 reg.get(&name)
678 .cloned()
679 .expect("repo just inserted must exist")
680 };
681 self.inner.config_store.upsert_repo(&repo).await?;
682 {
683 let mut snap = self.inner.snapshot.write().await;
684 if !snap.workspaces.iter().any(|r| r.name == name) {
685 snap.workspaces.push(Workspace {
686 name: name.clone(),
687 root: root.clone(),
688 sources: crate::caliband::sources::discover_sources(&root),
689 health: WorkspaceHealth::Healthy,
690 config: repo.config.clone(),
691 agents: Vec::new(),
692 });
693 }
694 }
695 self.poll_repo_once(&name).await;
696 Ok(())
697 }
698
699 pub async fn repo_config(&self, repo: &str) -> Option<crate::registry::RepoProviderConfig> {
701 self.inner
702 .registry
703 .read()
704 .await
705 .get(repo)
706 .map(|r| r.config.clone())
707 }
708
709 pub async fn remove_repo(&self, name: &str) -> Result<bool> {
711 let removed = {
712 let mut reg = self.inner.registry.write().await;
713 reg.remove(name)
714 };
715 if removed {
716 self.inner.config_store.delete_repo(name).await?;
718 self.inner
719 .snapshot
720 .write()
721 .await
722 .workspaces
723 .retain(|r| r.name != name);
724 self.inner.clients.lock().unwrap().remove(name);
725 }
726 Ok(removed)
727 }
728
729 pub async fn ensure_config_for(&self, repo: &str) -> Result<EnsureConfig> {
732 let cfg = {
733 let reg = self.inner.registry.read().await;
734 reg.get(repo)
735 .map(|r| r.config.clone())
736 .ok_or_else(|| CoreError::WorkspaceNotFound(repo.to_string()))?
737 };
738 let env = crate::provider_env::resolve_env(&self.inner.config.default_env, &cfg, &|k| {
739 std::env::var(k).ok()
740 });
741 let mut ensure = self.inner.config.ensure.clone();
742 ensure.env = env;
743 Ok(ensure)
744 }
745
746 pub async fn set_repo_config_registry_only(
748 &self,
749 repo: &str,
750 config: crate::registry::RepoProviderConfig,
751 ) -> Result<()> {
752 crate::provider_env::validate_provider_config(&config)
757 .map_err(CoreError::ProviderMisconfigured)?;
758 let mut reg = self.inner.registry.write().await;
764 if !reg.set_config(repo, config) {
765 return Err(CoreError::WorkspaceNotFound(repo.to_string()));
766 }
767 let record = reg
768 .get(repo)
769 .cloned()
770 .expect("repo exists after successful set_config");
771 self.inner.config_store.upsert_repo(&record).await?;
772 Ok(())
773 }
774
775 async fn client_for(&self, repo: &str) -> Result<CalibandClient> {
777 if let Some(c) = self.inner.clients.lock().unwrap().get(repo).cloned() {
778 return Ok(c);
779 }
780 if let Some(client) = self.inner.config.network_client()? {
784 self.inner
785 .clients
786 .lock()
787 .unwrap()
788 .insert(repo.to_string(), client.clone());
789 return Ok(client);
790 }
791 let root = {
792 let reg = self.inner.registry.read().await;
793 reg.get(repo)
794 .map(|r| r.root.clone())
795 .ok_or_else(|| CoreError::WorkspaceNotFound(repo.to_string()))?
796 };
797 let ensure = self.ensure_config_for(repo).await?;
798 let client = ensure_caliband(&root, &self.inner.config.discovery_env, &ensure).await?;
799 self.inner
800 .clients
801 .lock()
802 .unwrap()
803 .insert(repo.to_string(), client.clone());
804 Ok(client)
805 }
806
807 async fn validate_provider_env(&self, repo: &str) -> Result<()> {
812 let cfg = {
813 let reg = self.inner.registry.read().await;
814 reg.get(repo)
815 .map(|r| r.config.clone())
816 .ok_or_else(|| CoreError::WorkspaceNotFound(repo.to_string()))?
817 };
818 let env = crate::provider_env::resolve_env(&self.inner.config.default_env, &cfg, &|k| {
819 std::env::var(k).ok()
820 });
821 crate::provider_env::validate_provider_env(&cfg, &env)
822 .map_err(CoreError::ProviderMisconfigured)
823 }
824
825 pub async fn spawn_agent(&self, repo: &str, req: SpawnRequest) -> Result<String> {
827 Ok(self.spawn_agent_with_socket(repo, req).await?.0)
828 }
829
830 pub async fn spawn_agent_with_socket(
835 &self,
836 repo: &str,
837 req: SpawnRequest,
838 ) -> Result<(String, Endpoint)> {
839 self.validate_provider_env(repo).await?;
840 let client = self.client_for(repo).await?;
841 let req_timeout_secs = req.timeout_secs;
844 let mut spec = req.into_spec();
845 spec.provider = self.repo_config(repo).await.and_then(|c| c.provider);
850 let (id, endpoint) = client.spawn(spec).await?;
851 self.inner
855 .recent_spawns
856 .lock()
857 .unwrap()
858 .insert(id.clone(), repo.to_string());
859 self.inner
860 .emitter
861 .emit(repo, &id, EventKind::AgentSpawned)
862 .await;
863 if let Some(deadline) = crate::deadline::deadline_from(chrono::Utc::now(), req_timeout_secs)
867 {
868 self.inner
869 .deadlines
870 .lock()
871 .unwrap()
872 .insert(id.clone(), deadline.clone());
873 self.inner
874 .emitter
875 .emit(repo, &id, EventKind::DeadlineSet { deadline })
876 .await;
877 }
878 self.start_attach(repo, &id, client).await;
879 Ok((id, endpoint))
880 }
881
882 pub async fn kill_agent(&self, agent_id: &str) -> Result<()> {
884 let repo = self.repo_of(agent_id).await?;
885 self.client_for(&repo).await?.kill(agent_id).await
886 }
887
888 pub async fn send_agent_input(&self, agent_id: &str, input: AttachInbound) -> Result<()> {
896 let (repo, interactive, terminal) = {
897 let snap = self.inner.snapshot.read().await;
898 let (repo, agent) = snap
899 .find_agent(agent_id)
900 .ok_or_else(|| CoreError::AgentNotFound(agent_id.to_string()))?;
901 (
902 repo.to_string(),
903 agent.interactive,
904 agent.status.is_terminal(),
905 )
906 };
907 if terminal {
908 return Err(CoreError::InvalidState {
909 op: "send_input".into(),
910 id: agent_id.to_string(),
911 status: "terminal".into(),
912 });
913 }
914 if !interactive {
915 return Err(CoreError::InvalidState {
916 op: "send_input".into(),
917 id: agent_id.to_string(),
918 status: "not interactive".into(),
919 });
920 }
921 let client = self.client_for(&repo).await?;
922 let endpoint = client.attach(agent_id).await?;
923 client.send_inbound(&endpoint, &input).await
924 }
925
926 pub async fn respawn_agent(&self, agent_id: &str) -> Result<String> {
928 let repo = self.repo_of(agent_id).await?;
929 self.client_for(&repo).await?.respawn(agent_id).await
930 }
931
932 pub async fn drain_agent(&self, agent_id: &str, timeout: Duration) -> Result<()> {
940 let _ = self
941 .send_agent_input(agent_id, AttachInbound::EndInput)
942 .await;
943
944 let poll_interval = Duration::from_millis(20).min(timeout);
945 let deadline = tokio::time::Instant::now() + timeout;
946 while tokio::time::Instant::now() < deadline {
947 let snap = self.snapshot().await;
948 match snap.find_agent(agent_id) {
949 Some((_, agent)) if agent.status.is_terminal() => break,
950 None => break,
951 _ => tokio::time::sleep(poll_interval).await,
952 }
953 }
954
955 self.kill_agent(agent_id).await
956 }
957
958 pub fn watch_changes(&self) -> BoxStream<'static, FleetChange> {
970 let live = self.subscribe_all();
971
972 let seed_mgr = self.clone();
973 let seed = futures::stream::once(async move {
974 let snap = seed_mgr.snapshot().await;
975 let mut items = Vec::new();
976 for repo in snap.workspaces {
977 items.push(FleetChange::WorkspaceHealth {
978 workspace: repo.name.clone(),
979 health: repo.health.clone(),
980 });
981 for agent in repo.agents {
982 items.push(FleetChange::Discovered {
983 id: AgentId::from(agent.id.clone()),
984 workspace: repo.name.clone(),
985 agent,
986 });
987 }
988 }
989 futures::stream::iter(items)
990 });
991 let seed = futures::stream::StreamExt::flatten(seed);
992
993 let live_mgr = self.clone();
994 let live_changes = futures::stream::StreamExt::filter_map(live, move |be| {
995 let mgr = live_mgr.clone();
996 async move {
997 match be {
998 crate::bus::BusEvent::Event(ev) => event_to_change(&mgr, ev).await,
999 crate::bus::BusEvent::Lagged(n) => {
1004 tracing::warn!(
1005 target: "prospero_fleet", skipped = n,
1006 "watch_changes subscriber lagged; some live FleetChanges were dropped"
1007 );
1008 None
1009 }
1010 }
1011 }
1012 });
1013
1014 Box::pin(futures::stream::StreamExt::chain(seed, live_changes))
1015 }
1016
1017 pub async fn rm_agent(&self, agent_id: &str, force: bool) -> Result<()> {
1024 let repo = self.repo_of(agent_id).await?;
1025 self.client_for(&repo).await?.rm(agent_id, force).await?;
1026
1027 self.inner.recent_spawns.lock().unwrap().remove(agent_id);
1030
1031 let removed = {
1033 let mut snap = self.inner.snapshot.write().await;
1034 match snap.workspaces.iter_mut().find(|r| r.name == repo) {
1035 Some(r) => {
1036 let before = r.agents.len();
1037 r.agents.retain(|a| a.id != agent_id);
1038 r.agents.len() != before
1039 }
1040 None => false,
1041 }
1042 };
1043
1044 if removed
1052 && self
1053 .inner
1054 .ownership
1055 .try_acquire(&crate::event::stream_key_for(&repo, ""))
1056 .await
1057 .is_some()
1058 {
1059 self.inner
1060 .emitter
1061 .emit(&repo, agent_id, EventKind::AgentGone)
1062 .await;
1063 }
1064 Ok(())
1065 }
1066
1067 async fn repo_of(&self, agent_id: &str) -> Result<String> {
1068 if let Some(repo) = self
1069 .inner
1070 .snapshot
1071 .read()
1072 .await
1073 .find_agent(agent_id)
1074 .map(|(repo, _)| repo.to_string())
1075 {
1076 return Ok(repo);
1077 }
1078 self.inner
1082 .recent_spawns
1083 .lock()
1084 .unwrap()
1085 .get(agent_id)
1086 .cloned()
1087 .ok_or_else(|| CoreError::AgentNotFound(agent_id.to_string()))
1088 }
1089
1090 pub async fn poll_all_once(&self) {
1094 self.refresh_registry_from_store().await;
1095 let names: Vec<String> = {
1096 let reg = self.inner.registry.read().await;
1097 reg.workspaces.iter().map(|r| r.name.clone()).collect()
1098 };
1099 for name in names {
1100 self.poll_repo_once(&name).await;
1101 }
1102 }
1103
1104 async fn refresh_registry_from_store(&self) {
1111 let durable = {
1117 let mut reg = self.inner.registry.write().await;
1118 let durable = match self.inner.config_store.list_repos().await {
1119 Ok(repos) => repos,
1120 Err(e) => {
1121 tracing::warn!(
1122 target: "prospero_fleet", error = %e,
1123 "registry refresh from config store failed; keeping cached view"
1124 );
1125 return;
1126 }
1127 };
1128 reg.workspaces = durable.clone();
1129 durable
1130 };
1131 let mut snap = self.inner.snapshot.write().await;
1132 snap.workspaces
1133 .retain(|r| durable.iter().any(|d| d.name == r.name));
1134 for d in &durable {
1135 if !snap.workspaces.iter().any(|r| r.name == d.name) {
1136 snap.workspaces.push(Workspace {
1138 name: d.name.clone(),
1139 root: d.root.clone(),
1140 sources: crate::caliband::sources::discover_sources(&d.root),
1141 health: WorkspaceHealth::Healthy,
1142 config: d.config.clone(),
1143 agents: Vec::new(),
1144 });
1145 }
1146 }
1147 }
1148
1149 pub async fn poll_repo_once(&self, repo: &str) {
1153 self.inner.emitter.metrics.record_repo_poll();
1154 let own_lifecycle = self
1160 .inner
1161 .ownership
1162 .try_acquire(&crate::event::stream_key_for(repo, ""))
1163 .await
1164 .is_some();
1165 let client = match self.client_for(repo).await {
1166 Ok(c) => c,
1167 Err(e) => {
1168 self.mark_unreachable(repo, e.to_string(), own_lifecycle)
1169 .await;
1170 return;
1171 }
1172 };
1173 match client.list().await {
1174 Ok(records) => self.reconcile(repo, records, client, own_lifecycle).await,
1175 Err(e) => {
1176 self.inner.clients.lock().unwrap().remove(repo);
1179 self.mark_unreachable(repo, e.to_string(), own_lifecycle)
1180 .await;
1181 }
1182 }
1183 }
1184
1185 async fn mark_unreachable(&self, repo: &str, reason: String, own_lifecycle: bool) {
1186 let mut snap = self.inner.snapshot.write().await;
1187 if let Some(r) = snap.workspaces.iter_mut().find(|r| r.name == repo) {
1188 let new_health = WorkspaceHealth::Unreachable {
1189 reason: reason.clone(),
1190 };
1191 if r.health != new_health {
1192 r.health = new_health.clone();
1193 drop(snap);
1194 if own_lifecycle {
1197 self.inner
1198 .emitter
1199 .emit(repo, "", EventKind::RepoHealth { state: new_health })
1200 .await;
1201 }
1202 }
1203 }
1204 }
1205
1206 async fn reconcile(
1207 &self,
1208 repo: &str,
1209 records: Vec<AgentRecord>,
1210 client: CalibandClient,
1211 own_lifecycle: bool,
1212 ) {
1213 let prior: HashMap<String, AgentStatus> = {
1215 let snap = self.inner.snapshot.read().await;
1216 snap.workspaces
1217 .iter()
1218 .find(|r| r.name == repo)
1219 .map(|r| r.agents.iter().map(|a| (a.id.clone(), a.status)).collect())
1220 .unwrap_or_default()
1221 };
1222
1223 let mut new_agents = Vec::new();
1224 let mut to_attach: Vec<String> = Vec::new();
1225 let attached_now = self.inner.attached.lock().unwrap().clone();
1226
1227 for rec in &records {
1228 let agent = Agent {
1229 id: rec.id.clone(),
1230 name: rec.name.clone(),
1231 workspace: repo.to_string(),
1232 status: wire::domain_status(rec.status),
1234 started_at: rec.started_at.clone(),
1235 isolated: rec.spec.isolation_worktree,
1236 interactive: rec.spec.interactive,
1237 session_dir: rec.session_dir.clone(),
1238 reason: None,
1242 detail: None,
1243 permission_posture: match rec.spec.permission_posture {
1246 wire::WirePermissionPosture::Unattended => PermissionPosture::Unattended,
1247 wire::WirePermissionPosture::Supervised => PermissionPosture::Supervised,
1248 },
1249 };
1250 match prior.get(&rec.id) {
1251 None if own_lifecycle && !attached_now.contains(&rec.id) => {
1256 self.inner
1257 .emitter
1258 .emit(repo, &rec.id, EventKind::AgentDiscovered)
1259 .await;
1260 }
1261 None => {}
1262 Some(&old) if own_lifecycle && old != wire::domain_status(rec.status) => {
1264 self.inner
1265 .emitter
1266 .emit(
1267 repo,
1268 &rec.id,
1269 EventKind::StatusChanged {
1270 from: old,
1271 to: wire::domain_status(rec.status),
1272 },
1273 )
1274 .await;
1275 }
1276 _ => {}
1277 }
1278 if !wire::domain_status(rec.status).is_terminal() && !attached_now.contains(&rec.id) {
1284 to_attach.push(rec.id.clone());
1285 }
1286 new_agents.push(agent);
1287 }
1288
1289 for old_id in prior.keys() {
1292 if own_lifecycle && !records.iter().any(|r| &r.id == old_id) {
1293 self.inner
1294 .emitter
1295 .emit(repo, old_id, EventKind::AgentGone)
1296 .await;
1297 }
1298 }
1299
1300 {
1301 let mut snap = self.inner.snapshot.write().await;
1302 if let Some(r) = snap.workspaces.iter_mut().find(|r| r.name == repo) {
1303 let was_unreachable = matches!(r.health, WorkspaceHealth::Unreachable { .. });
1304 r.health = WorkspaceHealth::Healthy;
1305 r.agents = new_agents;
1306 if was_unreachable {
1307 drop(snap);
1308 if own_lifecycle {
1311 self.inner
1312 .emitter
1313 .emit(
1314 repo,
1315 "",
1316 EventKind::RepoHealth {
1317 state: WorkspaceHealth::Healthy,
1318 },
1319 )
1320 .await;
1321 }
1322 }
1323 }
1324 }
1325
1326 if !records.is_empty() {
1330 let mut recent = self.inner.recent_spawns.lock().unwrap();
1331 recent.retain(|id, _| !records.iter().any(|r| &r.id == id));
1332 }
1333
1334 for id in to_attach {
1335 self.start_attach(repo, &id, client.clone()).await;
1336 }
1337
1338 if own_lifecycle {
1342 self.enforce_deadlines(repo, &client).await;
1343 }
1344 }
1345
1346 async fn enforce_deadlines(&self, repo: &str, client: &CalibandClient) {
1354 let agents: Vec<Agent> = {
1355 let snap = self.inner.snapshot.read().await;
1356 snap.workspaces
1357 .iter()
1358 .find(|r| r.name == repo)
1359 .map(|r| r.agents.clone())
1360 .unwrap_or_default()
1361 };
1362
1363 for agent in agents.iter().filter(|a| !a.status.is_terminal()) {
1365 if self.inner.deadlines.lock().unwrap().contains_key(&agent.id) {
1366 continue;
1367 }
1368 let key = crate::event::stream_key_for(repo, &agent.id);
1369 let history = self
1370 .inner
1371 .emitter
1372 .store
1373 .replay(&key, 0)
1374 .await
1375 .unwrap_or_default();
1376 if let Some(deadline) = crate::deadline::deadline_in(&history) {
1377 self.inner
1378 .deadlines
1379 .lock()
1380 .unwrap()
1381 .insert(agent.id.clone(), deadline);
1382 }
1383 }
1384
1385 let due = {
1386 let deadlines = self.inner.deadlines.lock().unwrap();
1387 crate::deadline::expired(chrono::Utc::now(), &deadlines, agents.iter())
1388 };
1389 for (id, deadline) in due {
1390 self.inner
1393 .emitter
1394 .emit(repo, &id, EventKind::AgentTimedOut { deadline })
1395 .await;
1396 if let Err(e) = client.kill(&id).await {
1397 tracing::warn!(
1398 target: "prospero_fleet", agent = %id, error = %e,
1399 "killing a timed-out agent failed; will retry next poll"
1400 );
1401 continue;
1402 }
1403 self.inner.deadlines.lock().unwrap().remove(&id);
1407 }
1408 }
1409
1410 async fn start_attach(&self, repo: &str, agent_id: &str, client: CalibandClient) {
1414 if self.inner.attached.lock().unwrap().contains(agent_id) {
1417 return;
1418 }
1419 if self.inner.ownership.try_acquire(agent_id).await.is_none() {
1423 return;
1424 }
1425 {
1426 let mut attached = self.inner.attached.lock().unwrap();
1427 if !attached.insert(agent_id.to_string()) {
1428 return;
1432 }
1433 }
1434 let repo = repo.to_string();
1435 let agent_id = agent_id.to_string();
1436 let emitter = self.inner.emitter.clone();
1437 let normalize = self.inner.config.normalize;
1438 let backoff = self.inner.config.attach_backoff;
1439 let mut shutdown = self.inner.shutdown.subscribe();
1440 let attached = self.inner.clone();
1441
1442 tokio::spawn(async move {
1443 let result = attach_loop(
1444 &client,
1445 AttachTarget {
1448 repo: &repo,
1449 agent_id: &agent_id,
1450 attach_id: &agent_id,
1451 },
1452 &emitter,
1453 normalize,
1454 backoff,
1455 &mut shutdown,
1456 )
1457 .await;
1458 if let Err(e) = result {
1459 tracing::warn!(
1460 target: "prospero_fleet",
1461 %repo, %agent_id, error = %e,
1462 "attach task ended with error"
1463 );
1464 }
1465 attached.attached.lock().unwrap().remove(&agent_id);
1466 attached.ownership.release(&agent_id).await;
1468 });
1469 }
1470
1471 pub async fn cached_client_names(&self) -> Vec<String> {
1473 self.inner.clients.lock().unwrap().keys().cloned().collect()
1474 }
1475
1476 pub fn is_attached(&self, agent_id: &str) -> bool {
1478 self.inner.attached.lock().unwrap().contains(agent_id)
1479 }
1480
1481 pub async fn restart_caliband(&self, repo: &str) -> Result<()> {
1484 let client = self.inner.clients.lock().unwrap().get(repo).cloned();
1485 if let Some(client) = client {
1486 let res = client.shutdown().await;
1487 if let Err(e) = res {
1488 tracing::warn!(target: "prospero_fleet", repo, error = %e,
1489 "shutdown request to caliband failed (continuing)");
1490 }
1491 }
1492 self.inner.clients.lock().unwrap().remove(repo);
1493
1494 let root = {
1495 let reg = self.inner.registry.read().await;
1496 reg.get(repo).map(|r| r.root.clone())
1497 };
1498 if let Some(root) = root {
1499 let socket_res =
1500 crate::discovery::resolve_socket(&root, &self.inner.config.discovery_env);
1501 if let Ok(socket) = socket_res {
1502 let deadline =
1505 tokio::time::Instant::now() + self.inner.config.ensure.startup_timeout;
1506 while tokio::net::UnixStream::connect(&socket).await.is_ok() {
1507 if tokio::time::Instant::now() >= deadline {
1508 tracing::warn!(target: "prospero_fleet", repo,
1509 "old caliband socket still reachable after shutdown; proceeding");
1510 break;
1511 }
1512 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1513 }
1514 }
1515 }
1516 self.poll_repo_once(repo).await;
1517 Ok(())
1518 }
1519
1520 pub async fn set_repo_config(
1522 &self,
1523 repo: &str,
1524 config: crate::registry::RepoProviderConfig,
1525 ) -> Result<()> {
1526 self.set_repo_config_registry_only(repo, config).await?;
1527 self.restart_caliband(repo).await
1528 }
1529
1530 pub async fn run(self) {
1537 let interval = self.inner.config.poll_interval;
1538 let mut shutdown = self.inner.shutdown.subscribe();
1539 if *shutdown.borrow_and_update() {
1540 return;
1541 }
1542 loop {
1543 self.poll_all_once().await;
1544 tokio::select! {
1545 _ = tokio::time::sleep(interval) => {}
1546 _ = shutdown.changed() => break,
1547 }
1548 }
1549 tracing::info!(target: "prospero_fleet", "poll loop drained on shutdown");
1550 }
1551}
1552
1553async fn event_to_change(mgr: &FleetManager, ev: FleetEvent) -> Option<FleetChange> {
1576 match ev.kind {
1577 EventKind::AgentDiscovered => {
1578 const RETRY_BUDGET: Duration = Duration::from_millis(250);
1579 const RETRY_INTERVAL: Duration = Duration::from_millis(5);
1580 let deadline = Instant::now() + RETRY_BUDGET;
1581 loop {
1582 {
1583 let snap = mgr.snapshot().await;
1584 if let Some((_, agent)) = snap.find_agent(&ev.agent_id) {
1585 return Some(FleetChange::Discovered {
1586 id: AgentId::from(ev.agent_id.clone()),
1587 workspace: ev.repo.clone(),
1588 agent: agent.clone(),
1589 });
1590 }
1591 }
1592 if Instant::now() >= deadline {
1593 break;
1594 }
1595 tokio::time::sleep(RETRY_INTERVAL).await;
1596 }
1597 tracing::warn!(
1598 target: "prospero_fleet",
1599 agent_id = %ev.agent_id, repo = %ev.repo,
1600 "watch_changes: AgentDiscovered fired but the agent never appeared in the \
1601 snapshot within the retry budget; dropping the FleetChange"
1602 );
1603 None
1604 }
1605 EventKind::StatusChanged { from, to } => Some(FleetChange::StatusChanged {
1606 id: AgentId::from(ev.agent_id.clone()),
1607 workspace: ev.repo.clone(),
1608 from,
1609 to,
1610 }),
1611 EventKind::AgentGone => Some(FleetChange::Gone {
1612 id: AgentId::from(ev.agent_id.clone()),
1613 workspace: ev.repo.clone(),
1614 }),
1615 EventKind::RepoHealth { state } => Some(FleetChange::WorkspaceHealth {
1616 workspace: ev.repo.clone(),
1617 health: state,
1618 }),
1619 EventKind::AgentSpawned
1622 | EventKind::DeadlineSet { .. }
1626 | EventKind::AgentTimedOut { .. }
1627 | EventKind::AgentInit { .. }
1628 | EventKind::Output { .. }
1629 | EventKind::ToolStarted { .. }
1630 | EventKind::ToolFinished { .. }
1631 | EventKind::AgentFinished { .. }
1632 | EventKind::StorePersistFailed { .. } => None,
1633 }
1634}
1635
1636#[derive(Clone, Copy)]
1648pub(crate) struct AttachTarget<'a> {
1649 pub repo: &'a str,
1651 pub agent_id: &'a str,
1653 pub attach_id: &'a str,
1655}
1656
1657enum StreamOutcome {
1659 Finished,
1661 Disconnected,
1663}
1664
1665pub(crate) async fn attach_loop(
1688 client: &CalibandClient,
1689 target: AttachTarget<'_>,
1690 emitter: &Emitter,
1691 normalize: NormalizeOptions,
1692 backoff: AttachBackoff,
1693 shutdown: &mut watch::Receiver<bool>,
1694) -> Result<()> {
1695 let AttachTarget { repo, agent_id, .. } = target;
1696 let mut frames_seen: u64 = 0;
1697 let mut attempt: u32 = 0;
1698 loop {
1699 let before = frames_seen;
1700 let err = match attach_once(
1701 client,
1702 target,
1703 emitter,
1704 normalize,
1705 &mut frames_seen,
1706 shutdown,
1707 )
1708 .await
1709 {
1710 Ok(StreamOutcome::Finished) => return Ok(()),
1711 Ok(StreamOutcome::Disconnected) => None,
1712 Err(e) => Some(e),
1713 };
1714 if *shutdown.borrow() {
1716 return Ok(());
1717 }
1718 if frames_seen > before {
1720 attempt = 0;
1721 }
1722 if attempt >= backoff.max_retries {
1723 return match err {
1724 Some(e) => Err(e),
1725 None => {
1726 tracing::warn!(
1727 target: "prospero_fleet", %repo, %agent_id,
1728 "attach reconnection budget exhausted; poll loop will re-attach"
1729 );
1730 Ok(())
1731 }
1732 };
1733 }
1734 let delay = backoff.delay_for(agent_id, attempt);
1735 tracing::warn!(
1736 target: "prospero_fleet", %repo, %agent_id, attempt,
1737 delay_ms = delay.as_millis() as u64,
1738 reason = if err.is_some() { "error" } else { "premature-eof" },
1739 "attach stream dropped; reconnecting after backoff"
1740 );
1741 tokio::select! {
1742 _ = tokio::time::sleep(delay) => {}
1743 _ = shutdown.changed() => return Ok(()),
1744 }
1745 attempt += 1;
1746 }
1747}
1748
1749async fn attach_once(
1754 client: &CalibandClient,
1755 target: AttachTarget<'_>,
1756 emitter: &Emitter,
1757 normalize: NormalizeOptions,
1758 frames_seen: &mut u64,
1759 shutdown: &mut watch::Receiver<bool>,
1760) -> Result<StreamOutcome> {
1761 let AttachTarget {
1762 repo,
1763 agent_id,
1764 attach_id,
1765 } = target;
1766 let endpoint = client.attach(attach_id).await?;
1769 let mut reader = client.open_stream(&endpoint).await?;
1770 let mut line = String::new();
1771 let mut idx: u64 = 0;
1772 let mut saw_terminal = false;
1773 loop {
1774 line.clear();
1775 let n = tokio::select! {
1776 r = reader.read_line(&mut line) => r?,
1777 _ = shutdown.changed() => {
1778 return Ok(StreamOutcome::Finished);
1780 }
1781 };
1782 if n == 0 {
1783 return Ok(if saw_terminal {
1784 StreamOutcome::Finished
1785 } else {
1786 StreamOutcome::Disconnected
1787 });
1788 }
1789 let trimmed = line.trim_end();
1790 if trimmed.is_empty() {
1791 continue;
1792 }
1793 idx += 1;
1794 if idx <= *frames_seen {
1796 continue;
1797 }
1798 *frames_seen = idx;
1799 let frame: serde_json::Value = match serde_json::from_str(trimmed) {
1800 Ok(v) => v,
1801 Err(_) => {
1802 tracing::warn!(target: "prospero_fleet", %agent_id, "unparseable stream line");
1803 continue;
1804 }
1805 };
1806 match normalize_frame(&frame, normalize) {
1807 Normalized::Event(kind) => {
1808 if matches!(kind, EventKind::AgentFinished { .. }) {
1809 saw_terminal = true;
1810 }
1811 emitter.emit(repo, agent_id, kind).await;
1812 }
1813 Normalized::Dropped => {}
1814 Normalized::Unknown => {
1815 emitter.metrics.record_unknown_frame();
1816 tracing::debug!(target: "prospero_fleet", %agent_id, "unknown caliban frame type");
1817 }
1818 }
1819 }
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824 use super::*;
1825
1826 struct SlowListConfigStore {
1831 repos: std::sync::Mutex<Vec<crate::registry::RegisteredWorkspace>>,
1832 read_delay: Duration,
1833 }
1834 impl SlowListConfigStore {
1835 fn new(read_delay: Duration) -> Self {
1836 Self {
1837 repos: std::sync::Mutex::new(Vec::new()),
1838 read_delay,
1839 }
1840 }
1841 }
1842 #[async_trait::async_trait]
1843 impl crate::config_store::ConfigStore for SlowListConfigStore {
1844 async fn list_repos(&self) -> Result<Vec<crate::registry::RegisteredWorkspace>> {
1845 let snapshot = self.repos.lock().unwrap().clone(); tokio::time::sleep(self.read_delay).await; Ok(snapshot)
1848 }
1849 async fn upsert_repo(&self, repo: &crate::registry::RegisteredWorkspace) -> Result<()> {
1850 let mut v = self.repos.lock().unwrap();
1851 if let Some(e) = v.iter_mut().find(|e| e.name == repo.name) {
1852 *e = repo.clone();
1853 } else {
1854 v.push(repo.clone());
1855 }
1856 Ok(())
1857 }
1858 async fn delete_repo(&self, name: &str) -> Result<bool> {
1859 let mut v = self.repos.lock().unwrap();
1860 let before = v.len();
1861 v.retain(|e| e.name != name);
1862 Ok(v.len() != before)
1863 }
1864 async fn list_automations(&self) -> Result<Vec<crate::automation::StoredAutomation>> {
1869 unimplemented!("SlowListConfigStore only models list_repos")
1870 }
1871 async fn upsert_automation(
1872 &self,
1873 _automation: &crate::automation::StoredAutomation,
1874 ) -> Result<()> {
1875 unimplemented!("SlowListConfigStore only models list_repos")
1876 }
1877 async fn delete_automation(&self, _id: &str) -> Result<bool> {
1878 unimplemented!("SlowListConfigStore only models list_repos")
1879 }
1880 async fn claim_automation_fire(&self, _id: &str, _fire_at: &str) -> Result<bool> {
1881 unimplemented!("SlowListConfigStore only models list_repos")
1882 }
1883 async fn record_run(&self, _run: &crate::automation::AutomationRun) -> Result<()> {
1884 unimplemented!("SlowListConfigStore only models list_repos")
1885 }
1886 async fn list_runs(
1887 &self,
1888 _automation_id: &str,
1889 _limit: usize,
1890 ) -> Result<Vec<crate::automation::AutomationRun>> {
1891 unimplemented!("SlowListConfigStore only models list_repos")
1892 }
1893 }
1894
1895 #[tokio::test]
1896 async fn concurrent_refresh_does_not_clobber_a_just_set_config() {
1897 let dir = tempfile::tempdir().unwrap();
1898 let mut config = FleetConfig::new("local", dir.path());
1899 config.ensure.autostart = false;
1900 let root = dir.path().join("r");
1901 std::fs::create_dir_all(&root).unwrap();
1902
1903 let store: Arc<dyn crate::store::Store> =
1904 Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
1905 let cfg_store: Arc<dyn crate::config_store::ConfigStore> =
1906 Arc::new(SlowListConfigStore::new(Duration::from_millis(150)));
1907 let mgr = FleetManager::with_config_store(config, store, cfg_store)
1908 .await
1909 .unwrap();
1910 mgr.add_repo("r", &root).await.unwrap(); let m = mgr.clone();
1915 let refresh = tokio::spawn(async move { m.refresh_registry_from_store().await });
1916
1917 tokio::time::sleep(Duration::from_millis(30)).await;
1919 let cfg = crate::registry::RepoProviderConfig {
1920 provider: Some("openai".to_string()),
1921 ..Default::default()
1922 };
1923 mgr.set_repo_config_registry_only("r", cfg).await.unwrap();
1924 refresh.await.unwrap();
1925
1926 let snap = mgr.snapshot().await;
1928 let repo = snap.workspaces.iter().find(|w| w.name == "r").unwrap();
1929 assert_eq!(
1930 repo.config.provider.as_deref(),
1931 Some("openai"),
1932 "refresh clobbered a concurrent set_config back to durable; got {:?}",
1933 repo.config
1934 );
1935 }
1936
1937 #[test]
1938 fn fleet_config_network_yields_tcp_client() {
1939 let plain = FleetConfig::new("local", std::path::Path::new("/tmp/x"));
1942 assert!(plain.network_client().unwrap().is_none());
1943
1944 let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
1945 let mut cfg = FleetConfig::new("local", std::path::Path::new("/tmp/x"));
1946 cfg.caliband_network = Some(CalibandNetworkConfig {
1947 addr: "h:9443".into(),
1948 ca_pem: cert.cert.pem().into_bytes(),
1949 server_name: "localhost".into(),
1950 token: Some("t".into()),
1951 });
1952 let client = cfg.network_client().unwrap().expect("network client");
1953 assert!(matches!(client.endpoint(), Endpoint::Tcp { .. }));
1954 }
1955
1956 #[test]
1957 fn spawn_request_forwards_frontmatter_to_spec() {
1958 let mut req = SpawnRequest::new("p");
1960 assert_eq!(req.clone().into_spec().frontmatter_path, None);
1961 req.frontmatter_path = Some(std::path::PathBuf::from("/tpl.md"));
1962 assert_eq!(
1963 req.into_spec().frontmatter_path,
1964 Some(std::path::PathBuf::from("/tpl.md"))
1965 );
1966 }
1967
1968 #[test]
1971 fn spawn_request_forwards_permission_posture_to_spec() {
1972 let mut req = SpawnRequest::new("p");
1973 assert_eq!(
1974 req.clone().into_spec().permission_posture,
1975 wire::WirePermissionPosture::Supervised,
1976 "a plain request must spawn supervised"
1977 );
1978 req.permission_posture = PermissionPosture::Unattended;
1979 assert_eq!(
1980 req.into_spec().permission_posture,
1981 wire::WirePermissionPosture::Unattended
1982 );
1983 }
1984
1985 #[test]
1986 fn attach_backoff_is_exponential_capped_and_jittered() {
1987 let b = AttachBackoff {
1988 base: Duration::from_millis(100),
1989 max: Duration::from_millis(800),
1990 max_retries: 8,
1991 };
1992 for (attempt, exp_ms) in [(0u32, 100u64), (1, 200), (2, 400), (3, 800)] {
1994 let d = b.delay_for("agent-a", attempt).as_millis() as u64;
1995 assert!(
1996 d >= exp_ms / 2 && d <= exp_ms,
1997 "attempt {attempt}: {d}ms outside [{}, {exp_ms}]",
1998 exp_ms / 2
1999 );
2000 }
2001 let capped = b.delay_for("agent-a", 20).as_millis() as u64;
2003 assert!((400..=800).contains(&capped), "capped delay {capped}ms");
2004 assert_eq!(
2006 b.delay_for("agent-a", 2).as_millis(),
2007 b.delay_for("agent-a", 2).as_millis()
2008 );
2009 }
2010
2011 #[tokio::test]
2012 async fn restart_caliband_shuts_down_and_clears_client() {
2013 use crate::registry::RepoProviderConfig;
2014 use crate::testkit::FakeCaliband;
2015
2016 let dir = tempfile::tempdir().unwrap();
2017 let mut config = FleetConfig::new("local", dir.path());
2018 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2019 config.ensure.autostart = false; let root = dir.path().join("repo");
2021 std::fs::create_dir_all(&root).unwrap();
2022 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2023
2024 let fake = FakeCaliband::start_at(&socket).await.unwrap();
2025 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2026 let mgr = FleetManager::new(config, store).await.unwrap();
2027 mgr.add_repo("p", &root).await.unwrap();
2028
2029 mgr.poll_repo_once("p").await; mgr.set_repo_config("p", RepoProviderConfig::default())
2032 .await
2033 .unwrap();
2034
2035 assert_eq!(fake.shutdowns(), 1, "restart should send one Shutdown");
2036 assert!(
2037 mgr.cached_client_names().await.iter().all(|n| n != "p"),
2038 "cached client for the repo should be cleared after restart"
2039 );
2040 }
2041
2042 #[tokio::test]
2045 async fn a_spawn_with_a_timeout_is_killed_when_the_deadline_passes() {
2046 use crate::testkit::FakeCaliband;
2047
2048 let dir = tempfile::tempdir().unwrap();
2049 let mut config = FleetConfig::new("local", dir.path());
2050 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2051 config.ensure.autostart = false;
2052 let root = dir.path().join("repo");
2053 std::fs::create_dir_all(&root).unwrap();
2054 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2055
2056 let fake = FakeCaliband::start_at(&socket).await.unwrap();
2057 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2058 let mgr = FleetManager::new(config, store.clone()).await.unwrap();
2059 mgr.add_repo("p", &root).await.unwrap();
2060
2061 let mut req = SpawnRequest::new("run forever");
2063 req.timeout_secs = Some(0);
2064 let id = mgr.spawn_agent("p", req).await.unwrap();
2065
2066 let key = crate::event::stream_key_for("p", &id);
2069 let history = store.replay(&key, 0).await.unwrap();
2070 assert!(
2071 crate::deadline::deadline_in(&history).is_some(),
2072 "the spawn must record its deadline: {history:?}"
2073 );
2074
2075 mgr.poll_repo_once("p").await;
2076
2077 let history = store.replay(&key, 0).await.unwrap();
2078 assert!(
2079 history
2080 .iter()
2081 .any(|e| matches!(e.kind, EventKind::AgentTimedOut { .. })),
2082 "the expiry must be recorded: {history:?}"
2083 );
2084 assert!(
2085 fake.killed_ids().iter().any(|k| k == &id),
2086 "the agent must actually be killed, not just logged"
2087 );
2088 }
2089
2090 #[tokio::test]
2094 async fn a_deadline_set_by_another_replica_is_recovered_from_the_log() {
2095 use crate::model::AgentStatus;
2096 use crate::testkit::{FakeCaliband, test_record};
2097
2098 let dir = tempfile::tempdir().unwrap();
2099 let mut config = FleetConfig::new("survivor", dir.path());
2100 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2101 config.ensure.autostart = false;
2102 let root = dir.path().join("repo");
2103 std::fs::create_dir_all(&root).unwrap();
2104 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2105
2106 let mut fake = FakeCaliband::start_at(&socket).await.unwrap();
2109 fake.add_agent(
2110 test_record("orphan", dir.path(), AgentStatus::Running, false),
2111 vec![],
2112 )
2113 .await;
2114
2115 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2117 store
2118 .append(&FleetEvent {
2119 seq: 1,
2120 ts: "2026-09-20T00:00:00Z".into(),
2121 repo: "p".into(),
2122 agent_id: "orphan".into(),
2123 kind: EventKind::DeadlineSet {
2124 deadline: "2026-09-20T00:00:00+00:00".into(),
2125 },
2126 actor: None,
2127 on_behalf_of: None,
2128 })
2129 .await
2130 .unwrap();
2131
2132 let mgr = FleetManager::new(config, store).await.unwrap();
2133 mgr.add_repo("p", &root).await.unwrap();
2134 mgr.poll_repo_once("p").await;
2135
2136 assert!(
2137 fake.killed_ids().iter().any(|k| k == "orphan"),
2138 "a deadline this replica never set must still be enforced: {:?}",
2139 fake.killed_ids()
2140 );
2141 }
2142
2143 #[tokio::test]
2144 async fn send_agent_input_rejects_terminal_unknown_and_non_interactive() {
2145 use crate::caliband::wire::AttachInbound;
2146 use crate::model::AgentStatus;
2147 use crate::testkit::{FakeCaliband, test_record};
2148
2149 let dir = tempfile::tempdir().unwrap();
2150 let mut config = FleetConfig::new("local", dir.path());
2151 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2152 config.ensure.autostart = false;
2153 let root = dir.path().join("repo");
2154 std::fs::create_dir_all(&root).unwrap();
2155 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2156
2157 let mut fake = FakeCaliband::start_at(&socket).await.unwrap();
2158 let mut done = test_record("ag-done", dir.path(), AgentStatus::Done, false);
2160 done.spec.interactive = true;
2161 fake.add_agent(done, vec![]).await;
2162 let idle = test_record("ag-idle", dir.path(), AgentStatus::Idle, false);
2164 fake.add_agent(idle, vec![]).await;
2165
2166 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2167 let mgr = FleetManager::new(config, store).await.unwrap();
2168 mgr.add_repo("repo", &root).await.unwrap();
2169 mgr.poll_repo_once("repo").await;
2170
2171 let r1 = mgr
2172 .send_agent_input("ag-done", AttachInbound::EndInput)
2173 .await;
2174 assert!(
2175 matches!(r1, Err(CoreError::InvalidState { .. })),
2176 "terminal must reject"
2177 );
2178 let r2 = mgr
2179 .send_agent_input("ag-idle", AttachInbound::EndInput)
2180 .await;
2181 assert!(
2182 matches!(r2, Err(CoreError::InvalidState { .. })),
2183 "non-interactive must reject"
2184 );
2185 let r3 = mgr.send_agent_input("nope", AttachInbound::EndInput).await;
2186 assert!(
2187 matches!(r3, Err(CoreError::AgentNotFound(_))),
2188 "unknown id must 404"
2189 );
2190 }
2191
2192 #[tokio::test]
2193 async fn ownership_gates_the_attach_path() {
2194 use crate::testkit::FakeCaliband;
2197
2198 let dir = tempfile::tempdir().unwrap();
2199 let mut config = FleetConfig::new("local", dir.path());
2200 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2201 config.ensure.autostart = false;
2202 let root = dir.path().join("repo");
2203 std::fs::create_dir_all(&root).unwrap();
2204 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2205 let _fake = FakeCaliband::start_at(&socket).await.unwrap();
2206
2207 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2208 let mgr = FleetManager::new(config, store).await.unwrap();
2209 mgr.add_repo("p", &root).await.unwrap();
2210
2211 let id = mgr.spawn_agent("p", SpawnRequest::new("hi")).await.unwrap();
2212 assert!(mgr.is_attached(&id), "owned agent must be attached");
2215 }
2216
2217 #[tokio::test]
2218 async fn refused_ownership_blocks_the_attach_path() {
2219 use crate::bus::InProcessBus;
2220 use crate::ownership::{Lease, Ownership};
2221 use crate::testkit::FakeCaliband;
2222 use async_trait::async_trait;
2223
2224 struct NeverOwns;
2226 #[async_trait]
2227 impl Ownership for NeverOwns {
2228 async fn try_acquire(&self, _: &str) -> Option<Lease> {
2229 None
2230 }
2231 async fn renew(&self, _: &Lease) -> crate::error::Result<()> {
2232 Ok(())
2233 }
2234 async fn release(&self, _: &str) {}
2235 fn owns(&self, _: &str) -> bool {
2236 false
2237 }
2238 }
2239
2240 let dir = tempfile::tempdir().unwrap();
2241 let mut config = FleetConfig::new("local", dir.path());
2242 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2243 config.ensure.autostart = false;
2244 let root = dir.path().join("repo");
2245 std::fs::create_dir_all(&root).unwrap();
2246 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2247 let _fake = FakeCaliband::start_at(&socket).await.unwrap();
2248
2249 let store: Arc<dyn Store> = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2250 let config_store: Arc<dyn ConfigStore> = Arc::new(
2251 crate::config_store::SqliteConfigStore::open(dir.path())
2252 .await
2253 .unwrap(),
2254 );
2255 let bus: Arc<dyn EventBus> = Arc::new(InProcessBus::new(config.event_buffer));
2256 let ownership: Arc<dyn Ownership> = Arc::new(NeverOwns);
2257 let mgr = FleetManager::with_seams(config, store, config_store, bus, ownership)
2258 .await
2259 .unwrap();
2260 mgr.add_repo("p", &root).await.unwrap();
2261
2262 let id = mgr.spawn_agent("p", SpawnRequest::new("hi")).await.unwrap();
2263 assert!(
2264 !mgr.is_attached(&id),
2265 "peer-owned agent must NOT be attached locally"
2266 );
2267 }
2268
2269 #[tokio::test]
2270 async fn spawn_passes_repo_provider_into_spawnspec() {
2271 use crate::registry::RepoProviderConfig;
2272 use crate::testkit::FakeCaliband;
2273
2274 let dir = tempfile::tempdir().unwrap();
2275 let mut config = FleetConfig::new("local", dir.path());
2276 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
2277 config.ensure.autostart = false; let root = dir.path().join("repo");
2279 std::fs::create_dir_all(&root).unwrap();
2280 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
2281
2282 let fake = FakeCaliband::start_at(&socket).await.unwrap();
2283 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2284 let mgr = FleetManager::new(config, store).await.unwrap();
2285 mgr.add_repo("p", &root).await.unwrap();
2286 mgr.set_repo_config_registry_only(
2287 "p",
2288 RepoProviderConfig {
2289 provider: Some("openai".into()),
2290 base_url: Some("http://h:9292/v1".into()),
2293 ..Default::default()
2294 },
2295 )
2296 .await
2297 .unwrap();
2298
2299 mgr.spawn_agent("p", SpawnRequest::new("hi")).await.unwrap();
2300
2301 let specs = fake.received_specs();
2302 assert_eq!(specs.len(), 1, "exactly one spawn reached caliband");
2303 assert_eq!(
2304 specs[0].provider.as_deref(),
2305 Some("openai"),
2306 "the repo's configured provider must be carried in SpawnSpec.provider (#93)"
2307 );
2308 }
2309
2310 #[tokio::test]
2311 async fn ensure_config_for_merges_default_and_repo_config() {
2312 use crate::registry::RepoProviderConfig;
2313 let dir = tempfile::tempdir().unwrap();
2314 let mut config = FleetConfig::new("local", dir.path());
2315 config.default_env.insert("KEEP".into(), "global".into());
2316 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2317 let mgr = FleetManager::new(config, store).await.unwrap();
2318
2319 mgr.add_repo("p", "/tmp/p").await.ok(); let cfg = RepoProviderConfig {
2321 provider: Some("openai".into()),
2322 base_url: Some("http://h:9292/v1".into()),
2323 env: [("EXTRA".to_string(), "1".to_string())]
2324 .into_iter()
2325 .collect(),
2326 ..Default::default()
2327 };
2328 mgr.set_repo_config_registry_only("p", cfg).await.unwrap();
2329
2330 let ec = mgr.ensure_config_for("p").await.unwrap();
2331 assert_eq!(ec.env.get("KEEP").unwrap(), "global");
2332 assert_eq!(ec.env.get("CALIBAN_PROVIDER").unwrap(), "openai");
2333 assert_eq!(ec.env.get("OPENAI_BASE_URL").unwrap(), "http://h:9292/v1");
2334 assert_eq!(ec.env.get("EXTRA").unwrap(), "1");
2335 }
2336
2337 struct FlakyStore {
2341 inner: crate::store::JsonlStore,
2342 fail_seqs: std::sync::Mutex<std::collections::HashSet<u64>>,
2343 }
2344
2345 impl FlakyStore {
2346 fn new(inner: crate::store::JsonlStore, fail: impl IntoIterator<Item = u64>) -> Self {
2347 Self {
2348 inner,
2349 fail_seqs: std::sync::Mutex::new(fail.into_iter().collect()),
2350 }
2351 }
2352 }
2353
2354 #[async_trait::async_trait]
2355 impl Store for FlakyStore {
2356 async fn append(&self, event: &FleetEvent) -> Result<()> {
2357 if self.fail_seqs.lock().unwrap().contains(&event.seq) {
2358 return Err(CoreError::Store("injected append failure".into()));
2359 }
2360 self.inner.append(event).await
2361 }
2362 async fn replay(&self, stream_key: &str, from_seq: u64) -> Result<Vec<FleetEvent>> {
2363 self.inner.replay(stream_key, from_seq).await
2364 }
2365 async fn replay_fleet(
2366 &self,
2367 after_cursor: u64,
2368 limit: usize,
2369 ) -> Result<Vec<crate::store::CursoredEvent>> {
2370 self.inner.replay_fleet(after_cursor, limit).await
2371 }
2372 async fn latest_fleet_cursor(&self) -> Result<u64> {
2373 self.inner.latest_fleet_cursor().await
2374 }
2375 async fn high_water(&self, stream_key: &str) -> Result<u64> {
2376 self.inner.high_water(stream_key).await
2377 }
2378 async fn writable(&self) -> bool {
2379 self.inner.writable().await
2380 }
2381 async fn prune(&self, before_ts: &str) -> Result<u64> {
2382 self.inner.prune(before_ts).await
2383 }
2384 async fn usage(&self, since: &str, until: &str) -> Result<Vec<crate::store::UsageRow>> {
2385 self.inner.usage(since, until).await
2386 }
2387 }
2388
2389 fn emitter_with(store: Arc<dyn Store>) -> Emitter {
2390 Emitter {
2391 store,
2392 bus: Arc::new(InProcessBus::new(16)),
2393 seqs: Arc::new(AsyncMutex::new(HashMap::new())),
2394 metrics: Arc::new(Metrics::default()),
2395 }
2396 }
2397
2398 fn ev(seq: u64, agent: &str, chunk: &str) -> FleetEvent {
2399 use crate::event::OutputStream;
2400 FleetEvent {
2401 seq,
2402 ts: "t".into(),
2403 repo: "r".into(),
2404 agent_id: agent.into(),
2405 actor: None,
2406 on_behalf_of: None,
2407 kind: EventKind::Output {
2408 stream: OutputStream::Stdout,
2409 chunk: chunk.into(),
2410 },
2411 }
2412 }
2413
2414 #[tokio::test]
2415 async fn seq_is_monotonic_per_stream_not_global() {
2416 let dir = tempfile::tempdir().unwrap();
2417 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2418 let emitter = emitter_with(store);
2419
2420 emitter.emit("r", "a1", EventKind::AgentSpawned).await;
2422 emitter.emit("r", "a2", EventKind::AgentSpawned).await;
2423 emitter.emit("r", "a1", EventKind::AgentGone).await;
2424
2425 let a1 = emitter
2426 .store
2427 .replay(&crate::event::stream_key_for("r", "a1"), 0)
2428 .await
2429 .unwrap();
2430 let a2 = emitter
2431 .store
2432 .replay(&crate::event::stream_key_for("r", "a2"), 0)
2433 .await
2434 .unwrap();
2435 assert_eq!(a1.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
2436 assert_eq!(a2.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
2437 }
2438
2439 #[tokio::test]
2440 async fn seq_resumes_per_stream_from_high_water() {
2441 let dir = tempfile::tempdir().unwrap();
2442 {
2444 let store = crate::store::JsonlStore::open(dir.path()).unwrap();
2445 store.append(&ev(5, "a1", "old")).await.unwrap();
2446 }
2447 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2448 let emitter = emitter_with(store);
2449 emitter.emit("r", "a1", EventKind::AgentGone).await;
2450
2451 let a1 = emitter
2452 .store
2453 .replay(&crate::event::stream_key_for("r", "a1"), 0)
2454 .await
2455 .unwrap();
2456 assert_eq!(a1.last().unwrap().seq, 6);
2458 }
2459
2460 #[tokio::test]
2461 async fn emit_retries_on_seq_conflict_instead_of_dropping() {
2462 let dir = tempfile::tempdir().unwrap();
2467 let store = Arc::new(
2468 crate::sqlite_store::SqliteStore::open(dir.path())
2469 .await
2470 .unwrap(),
2471 );
2472 let emitter = emitter_with(store.clone());
2473
2474 emitter.emit("r", "a1", EventKind::AgentSpawned).await;
2476
2477 store.append(&ev(2, "a1", "from-peer")).await.unwrap();
2479
2480 emitter.emit("r", "a1", EventKind::AgentGone).await;
2483
2484 let hist = store
2485 .replay(&crate::event::stream_key_for("r", "a1"), 0)
2486 .await
2487 .unwrap();
2488 assert_eq!(
2489 hist.iter().map(|e| e.seq).collect::<Vec<_>>(),
2490 vec![1, 2, 3],
2491 "the racing event must be preserved at the next free seq, not dropped"
2492 );
2493 assert!(
2494 matches!(hist.last().unwrap().kind, EventKind::AgentGone),
2495 "the retried emitter event lands at seq 3: {:?}",
2496 hist.last().unwrap().kind
2497 );
2498 assert_eq!(
2499 emitter.metrics.snapshot(0).append_failures,
2500 0,
2501 "a resolved conflict must not count as a persist failure"
2502 );
2503 }
2504
2505 #[tokio::test]
2506 async fn append_failure_emits_persist_gap_marker_visible_to_history() {
2507 use crate::event::OutputStream;
2508
2509 let dir = tempfile::tempdir().unwrap();
2510 let inner = crate::store::JsonlStore::open(dir.path()).unwrap();
2511 let store = Arc::new(FlakyStore::new(inner, [1])); let emitter = emitter_with(store.clone());
2513 use tokio_stream::StreamExt;
2514 let mut sub = emitter
2515 .bus
2516 .subscribe(&crate::event::stream_key_for("repo", "a1"));
2517
2518 emitter
2519 .emit(
2520 "repo",
2521 "a1",
2522 EventKind::Output {
2523 stream: OutputStream::Stdout,
2524 chunk: "lost".into(),
2525 },
2526 )
2527 .await;
2528
2529 let ev = match sub.next().await {
2531 Some(crate::bus::BusEvent::Event(ev)) => ev,
2532 other => panic!("expected a live event, got {other:?}"),
2533 };
2534 assert_eq!(ev.seq, 1);
2535 assert!(matches!(ev.kind, EventKind::Output { .. }));
2536 let marker = match sub.next().await {
2538 Some(crate::bus::BusEvent::Event(ev)) => ev,
2539 other => panic!("expected a live event, got {other:?}"),
2540 };
2541 assert_eq!(marker.agent_id, "a1");
2542 assert!(matches!(
2543 marker.kind,
2544 EventKind::StorePersistFailed { lost_seq: 1, .. }
2545 ));
2546
2547 let history = store.replay("a1", 0).await.unwrap();
2550 assert!(
2551 history
2552 .iter()
2553 .any(|e| matches!(e.kind, EventKind::StorePersistFailed { lost_seq: 1, .. })),
2554 "history reader must see the persist-gap marker"
2555 );
2556 assert!(
2557 !history.iter().any(|e| e.seq == 1),
2558 "the un-persisted event must not appear in durable history"
2559 );
2560 }
2561
2562 #[tokio::test]
2563 async fn healthy_append_emits_no_gap_marker() {
2564 let dir = tempfile::tempdir().unwrap();
2565 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2566 let emitter = emitter_with(store);
2567 use tokio_stream::StreamExt;
2568 let mut sub = emitter
2569 .bus
2570 .subscribe(&crate::event::stream_key_for("repo", "a1"));
2571
2572 emitter.emit("repo", "a1", EventKind::AgentSpawned).await;
2573
2574 let ev = match sub.next().await {
2575 Some(crate::bus::BusEvent::Event(ev)) => ev,
2576 other => panic!("expected a live event, got {other:?}"),
2577 };
2578 assert!(matches!(ev.kind, EventKind::AgentSpawned));
2579 assert!(
2581 tokio::time::timeout(std::time::Duration::from_millis(100), sub.next())
2582 .await
2583 .is_err(),
2584 "a healthy append must not emit a gap marker"
2585 );
2586 }
2587
2588 #[tokio::test]
2589 async fn append_failure_and_success_advance_metrics() {
2590 let dir = tempfile::tempdir().unwrap();
2591 let inner = crate::store::JsonlStore::open(dir.path()).unwrap();
2592 let store = Arc::new(FlakyStore::new(inner, [1]));
2594 let emitter = emitter_with(store);
2595
2596 emitter.emit("repo", "a1", EventKind::AgentSpawned).await;
2597
2598 let m = emitter.metrics.snapshot(0);
2599 assert_eq!(m.append_failures, 1, "the failed append must be counted");
2600 assert_eq!(
2601 m.events_appended, 1,
2602 "the successful gap-marker append must be counted"
2603 );
2604 }
2605
2606 #[tokio::test]
2607 async fn run_drains_and_returns_on_shutdown() {
2608 let dir = tempfile::tempdir().unwrap();
2609 let mut config = FleetConfig::new("local", dir.path());
2610 config.poll_interval = Duration::from_millis(50);
2611 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2612 let mgr = FleetManager::new(config, store).await.unwrap();
2613
2614 let signaller = mgr.clone();
2615 let handle = tokio::spawn(mgr.run());
2616 signaller.begin_shutdown();
2617
2618 tokio::time::timeout(Duration::from_secs(2), handle)
2621 .await
2622 .expect("run() must return after begin_shutdown")
2623 .expect("run task panicked");
2624 }
2625
2626 #[tokio::test]
2627 async fn prune_older_than_removes_aged_events() {
2628 let dir = tempfile::tempdir().unwrap();
2629 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2630 store
2631 .append(&FleetEvent {
2632 seq: 1,
2633 ts: "2000-01-01T00:00:00+00:00".into(),
2634 repo: "r".into(),
2635 agent_id: "a".into(),
2636 kind: EventKind::AgentSpawned,
2637 actor: None,
2638 on_behalf_of: None,
2639 })
2640 .await
2641 .unwrap();
2642 store
2643 .append(&FleetEvent {
2644 seq: 2,
2645 ts: chrono::Utc::now().to_rfc3339(),
2646 repo: "r".into(),
2647 agent_id: "a".into(),
2648 kind: EventKind::AgentGone,
2649 actor: None,
2650 on_behalf_of: None,
2651 })
2652 .await
2653 .unwrap();
2654
2655 let config = FleetConfig::new("local", dir.path());
2656 let mgr = FleetManager::new(config, store).await.unwrap();
2657
2658 let removed = mgr
2659 .prune_older_than(std::time::Duration::from_secs(24 * 3600))
2660 .await
2661 .unwrap();
2662 assert_eq!(removed, 1);
2663 assert_eq!(mgr.history("a", 0).await.unwrap().len(), 1);
2664 }
2665
2666 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2667 async fn concurrent_seed_of_same_stream_assigns_unique_seqs() {
2668 let dir = tempfile::tempdir().unwrap();
2669 let store = Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
2670 let emitter = emitter_with(store);
2671 let e1 = emitter.clone();
2673 let e2 = emitter.clone();
2674 let t1 = tokio::spawn(async move { e1.emit("r", "a1", EventKind::AgentSpawned).await });
2675 let t2 = tokio::spawn(async move { e2.emit("r", "a1", EventKind::AgentGone).await });
2676 t1.await.unwrap();
2677 t2.await.unwrap();
2678 let a1 = emitter
2679 .store
2680 .replay(&crate::event::stream_key_for("r", "a1"), 0)
2681 .await
2682 .unwrap();
2683 let seqs: Vec<u64> = a1.iter().map(|e| e.seq).collect();
2684 assert_eq!(seqs.len(), 2, "both events persisted");
2685 assert_ne!(seqs[0], seqs[1], "no duplicate seq within a stream");
2686 }
2687}