Skip to main content

prospero_core/
fleet.rs

1//! The runtime heart of the control plane.
2//!
3//! `FleetManager` owns the in-memory [`FleetSnapshot`], polls each managed
4//! repo's caliband for live state, attaches per-agent stream sockets while
5//! agents are active, normalizes frames into [`FleetEvent`]s, and fans them out
6//! over a broadcast bus while also appending them to the durable [`Store`].
7
8use 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/// A Prospero-level request to launch a new agent. Worktree isolation is the
36/// default for parallel work on one codebase; opt out with `isolation_worktree:
37/// false`.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct SpawnRequest {
40    /// Initial prompt / task.
41    pub prompt: String,
42    /// Optional human-readable label.
43    pub label: Option<String>,
44    /// Optional model override.
45    pub model: Option<String>,
46    /// Run in an isolated git worktree. **Defaults to `true`** via
47    /// [`SpawnRequest::new`].
48    pub isolation_worktree: bool,
49    /// Optional tool allowlist.
50    pub tool_allowlist: Option<Vec<String>>,
51    /// Run in interactive mode (the worker awaits operator input instead of
52    /// finishing). Defaults to `false` via [`SpawnRequest::new`].
53    pub interactive: bool,
54    /// Optional agent-template / frontmatter markdown file, forwarded to
55    /// caliband's `SpawnSpec.frontmatter_path`. `None` ⇒ no template (#6).
56    pub frontmatter_path: Option<PathBuf>,
57    /// Kill the agent after this many seconds of wall-clock time (#221).
58    /// `None` ⇒ it runs until it finishes or someone stops it.
59    ///
60    /// Enforced by prospero, not caliband: the deadline is recorded in the
61    /// event log at spawn, so it survives the replica that set it.
62    pub timeout_secs: Option<u64>,
63    /// Which of the target workspace's named providers to bind, under the k8s
64    /// config plane (→ `CalibanTask.spec.providerRef`). `None` ⇒ the operator
65    /// picks the workspace's `defaultProvider`. Ignored by `LocalFleet`, whose
66    /// provider comes from the repo's stored config. (#142)
67    pub provider_ref: Option<String>,
68    /// How this session handles tool calls needing permission (#238). Defaults
69    /// to [`PermissionPosture::Supervised`] via [`SpawnRequest::new`].
70    /// Authorization for `Unattended` is the caller's job — the API requires an
71    /// `admin` scope (ADR-0010) and, in-cluster, the operator additionally
72    /// requires the Workspace to allow it.
73    pub permission_posture: PermissionPosture,
74}
75
76impl SpawnRequest {
77    /// A spawn request with worktree isolation on by default.
78    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            // Filled in `spawn_agent` from the repo's stored provider config —
100            // the request itself carries no provider.
101            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            // Fields prospero does not use. `inherited_hooks_config` is
108            // caliban's opaque parent-permission bridge, superseded for posture
109            // by `permission_posture` (ADR 0059); `source` and `resume_session`
110            // belong to caliban's own sub-agent flows; `drive_protocol` stays
111            // NDJSON until the ACP driver lands (#242, ADR-0011).
112            inherited_hooks_config: None,
113            source: None,
114            resume_session: None,
115            drive_protocol: wire::DriveProtocol::Ndjson,
116        }
117    }
118}
119
120/// Configuration for a [`FleetManager`].
121#[derive(Debug, Clone)]
122pub struct FleetConfig {
123    /// Host identity (single host in the first stab).
124    pub host: String,
125    /// Directory for the config store and event store (both in `events.db`).
126    pub data_dir: PathBuf,
127    /// How often the poll loop refreshes each repo.
128    pub poll_interval: Duration,
129    /// Environment used for caliband socket discovery.
130    pub discovery_env: DiscoveryEnv,
131    /// Daemon autostart configuration.
132    pub ensure: EnsureConfig,
133    /// Stream normalization options.
134    pub normalize: NormalizeOptions,
135    /// Broadcast channel capacity (events buffered for slow subscribers).
136    pub event_buffer: usize,
137    /// Global default env merged under each repo's resolved overlay.
138    pub default_env: std::collections::BTreeMap<String, String>,
139    /// Reconnection backoff for a dropped per-agent attach stream.
140    pub attach_backoff: AttachBackoff,
141    /// Optional network transport for caliband (ADR 0051): when `Some`, the
142    /// manager dials this TCP endpoint over TLS + bearer token instead of
143    /// resolving a local Unix control socket. This is the threading seam for
144    /// #71; production *discovery* of a per-workspace endpoint
145    /// (env/Secret/Sandbox-DNS) is deferred to prospero #64/#72.
146    pub caliband_network: Option<CalibandNetworkConfig>,
147}
148
149/// Materials to dial a caliband control endpoint over TCP + rustls TLS + a
150/// bearer token (ADR 0051). PEM bytes rather than a built `TlsClient` so the
151/// config stays cloneable/`Debug` and mirrors how an operator would carry it
152/// (a mounted Secret).
153#[derive(Debug, Clone)]
154pub struct CalibandNetworkConfig {
155    /// `host:port` of the caliband control endpoint.
156    pub addr: String,
157    /// PEM the client trusts (the CA, or a self-signed server cert).
158    pub ca_pem: Vec<u8>,
159    /// Expected server name (SNI / cert validation target).
160    pub server_name: String,
161    /// Bearer token presented after the TLS handshake. `None` = no token.
162    pub token: Option<String>,
163}
164
165/// Bounded exponential-backoff policy for reconnecting a dropped attach stream.
166///
167/// On a premature stream drop (EOF before the agent's terminal `result`, or a
168/// read error) the attach task waits `min(max, base * 2^attempt)` — with a
169/// per-(agent, attempt) jitter to decorrelate reconnect storms across agents —
170/// then reconnects, up to `max_retries` consecutive failures. Making progress
171/// (new frames) resets the attempt counter. When the budget is exhausted the
172/// task exits and the poll loop remains the long-term re-attach safety net.
173#[derive(Debug, Clone, Copy)]
174pub struct AttachBackoff {
175    /// Delay before the first reconnect.
176    pub base: Duration,
177    /// Ceiling on any single backoff delay.
178    pub max: Duration,
179    /// Maximum consecutive reconnect attempts before giving up in-path.
180    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    /// Jittered delay for a 0-based `attempt`. Exponential on `base`, capped at
195    /// `max`, then scaled by a deterministic per-(agent, attempt) factor in
196    /// `[0.5, 1.0)` so concurrent attaches don't reconnect in lockstep.
197    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    /// A config rooted at `data_dir` with sensible first-stab defaults.
213    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    /// Build the network control client when [`Self::caliband_network`] is set,
229    /// else `None` (the caller falls back to Unix discovery). The single
230    /// threading seam for #71 — one networked caliband for the manager;
231    /// per-workspace endpoint resolution is prospero #64/#72.
232    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/// Stamps and dispatches events; cheaply cloneable into background tasks.
249///
250/// `pub(crate)` (not private) so [`crate::k8s::fleet::K8sFleet`]'s network
251/// session-plane bridge (ADR 0008 §3) can share this exact attach→bus/store
252/// path via [`Emitter::new`] + [`attach_loop`] — the same durable history +
253/// live bus `FleetManager` feeds, so `/stream` SSE and history work unchanged
254/// for a k8s-backed agent.
255#[derive(Clone)]
256pub(crate) struct Emitter {
257    store: Arc<dyn Store>,
258    bus: Arc<dyn EventBus>,
259    /// Next `seq` per stream key, seeded lazily from the store's high-water.
260    seqs: Arc<AsyncMutex<HashMap<String, u64>>>,
261    metrics: Arc<Metrics>,
262}
263
264impl Emitter {
265    /// Build a fresh `Emitter` over `bus`/`store` with an empty seq cache and
266    /// its own metrics recorder. `FleetManager::with_seams` builds its
267    /// `Emitter` inline (unchanged, to avoid touching its construction); this
268    /// constructor exists for other callers in-crate — currently `K8sFleet`
269    /// (Task B4) — that need the identical bus/store plumbing. `cfg`-gated on
270    /// `k8s` (its only caller) so a `LocalFleet`-only build stays warning-free.
271    #[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    /// The shared event store, for a backend that needs to probe it directly
282    /// (K8sFleet's `readiness`). (#76)
283    #[cfg(feature = "k8s")]
284    pub(crate) fn store(&self) -> Arc<dyn Store> {
285        self.store.clone()
286    }
287
288    /// A metrics snapshot with `active` as the live attach gauge — the same
289    /// recorder `FleetManager::metrics` reads. (#76)
290    #[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    /// Allocate the next per-stream `seq`. The durable `high_water` read on a
310    /// stream's first event happens WITHOUT holding the `seqs` lock, so the lock
311    /// is never held across `.await` (no executor stall / cross-stream serialize).
312    async fn next_seq(&self, stream_key: &str) -> u64 {
313        // Fast path: counter already seeded for this stream this run.
314        {
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        // Slow path: first event this run — read durable high-water unlocked.
323        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        // Invariant: `emit` records the seq in `seqs` (here) BEFORE it calls
331        // `store.append`, so a concurrent task that read a stale `high_water`
332        // during the append window still sees the up-to-date counter on its
333        // re-check below — keeping `seq` unique and monotonic per stream.
334        let mut seqs = self.seqs.lock().await;
335        // Re-check: a concurrent task may have seeded while we read high_water.
336        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    /// Sequence, persist, and publish one event.
345    ///
346    /// `pub(crate)` — matching `Emitter` itself — so the k8s watch loop can
347    /// persist the transitions it observes (#190), the same way this module's
348    /// `reconcile` does for the local arm.
349    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        // Live SSE flows regardless of persistence (ADR-0004 favors a never-down
358        // fleet view). Ignore send errors: no subscribers is fine. Publish the
359        // event with its FINAL seq (a conflict retry may have bumped it).
360        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    /// Append `event`, re-seeding its `seq` from the durable high-water and
368    /// retrying when a concurrent writer (another replica) took the same
369    /// per-stream seq. This preserves the event on a benign cross-replica race
370    /// instead of dropping it. Returns the terminal error, if any. (#49)
371    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                    // The winning write is now durable, so re-reading the
381                    // high-water yields a fresh, higher seq.
382                    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    /// Forget the cached counter for `stream_key` so the next [`next_seq`] call
391    /// re-reads the durable high-water (which reflects the winning write).
392    async fn reseed_seq(&self, stream_key: &str) {
393        self.seqs.lock().await.remove(stream_key);
394    }
395
396    /// Record a durable-store divergence: the event at `lost_seq` reached the
397    /// live bus but not durable history. Emits a [`EventKind::StorePersistFailed`]
398    /// marker — persisted best-effort so a history reader sees the gap, and sent
399    /// on the bus so live consumers know history is incomplete. The marker keeps
400    /// the live and durable views from silently diverging (#25, ADR-0004).
401    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                // Hard-down store: the gap is now observable only via logs
416                // (documented degradation), but live consumers are still signalled.
417                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    /// Per-repo control clients, cached after first discovery.
431    clients: Mutex<HashMap<String, CalibandClient>>,
432    /// Agent ids with a running attach task.
433    attached: Mutex<HashSet<String>>,
434    /// Recently-spawned agent ids → their repo, populated at spawn time and
435    /// consulted by [`Self::repo_of`] to bridge the gap before the first poll
436    /// lands the agent in the snapshot. Without it a `DELETE`/`kill` issued
437    /// immediately after `spawn` would `AgentNotFound` (404) because the cached
438    /// snapshot has not caught up yet (#122). Entries are pruned once the poll
439    /// observes the agent (snapshot becomes authoritative) or on `rm`.
440    recent_spawns: Mutex<HashMap<String, String>>,
441    /// Wall-clock deadlines by agent id (#221), as RFC-3339.
442    ///
443    /// A cache of what the event log already says, not the record itself: an
444    /// agent this replica did not spawn is filled in from its history the first
445    /// time the poll loop sees it, which is what makes a deadline survive
446    /// failover.
447    deadlines: Mutex<HashMap<String, String>>,
448    emitter: Emitter,
449    ownership: Arc<dyn Ownership>,
450    /// Broadcast shutdown signal: `true` once a graceful drain has begun. The
451    /// poll loop and attach tasks subscribe and stop cooperatively.
452    shutdown: watch::Sender<bool>,
453}
454
455/// The fleet control plane.
456#[derive(Clone)]
457pub struct FleetManager {
458    inner: Arc<Inner>,
459}
460
461impl FleetManager {
462    /// Build a manager, loading the persisted registry from a default
463    /// [`SqliteConfigStore`] in `config.data_dir` (the same dir as the event
464    /// store). For an injected config backend (e.g. Postgres), use
465    /// [`Self::with_config_store`].
466    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    /// Build a manager with an explicit [`ConfigStore`] and the standalone
472    /// `EventBus`/`Ownership` seams.
473    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    /// Build a manager with every topology seam injected. Standalone passes
484    /// `InProcessBus` + `SelfOwnsAll`; clustered (Phase 2d) passes
485    /// `DistributedBus` + `LeasedOwnership`.
486    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    /// Signal a graceful shutdown: the poll loop finishes its in-flight cycle and
535    /// returns, and attach tasks stop reading between frames. Idempotent.
536    ///
537    /// Uses `send_replace` so the signal sticks even if no task has subscribed
538    /// yet (plain `send` is a no-op when there are no receivers).
539    pub fn begin_shutdown(&self) {
540        self.inner.shutdown.send_replace(true);
541    }
542
543    /// Subscribe to one stream's live event tail (see [`crate::EventBus`]).
544    /// Watchers of a single agent pass the agent id (its stream key); repo/fleet
545    /// watchers pass `repo:<name>` / `fleet`.
546    pub fn subscribe(&self, stream_key: &str) -> crate::bus::BusSubscription {
547        self.inner.emitter.bus.subscribe(stream_key)
548    }
549
550    /// The shared event store. Observability reads (agent history/SSE) route
551    /// here rather than through the fleet backend, so any `FleetProvider`
552    /// (local or k8s) that emits to this store serves the same read path. (#76)
553    #[must_use]
554    pub fn store(&self) -> Arc<dyn Store> {
555        self.inner.emitter.store.clone()
556    }
557
558    /// The shared event bus (SSE `subscribe` routes here). See [`Self::store`]. (#76)
559    #[must_use]
560    pub fn bus(&self) -> Arc<dyn EventBus> {
561        self.inner.emitter.bus.clone()
562    }
563
564    /// Subscribe to every stream's live events, unfiltered (see
565    /// [`crate::bus::EventBus::subscribe_all`]). [`Self::watch_changes`] needs
566    /// this rather than [`Self::subscribe`]: a brand-new agent's own id (its
567    /// stream key) isn't knowable until *after* its `AgentDiscovered` event has
568    /// already fired, so a fleet-wide watcher can't pre-subscribe to it.
569    fn subscribe_all(&self) -> crate::bus::BusSubscription {
570        self.inner.emitter.bus.subscribe_all()
571    }
572
573    /// A clone of the current fleet snapshot, with each repo's provider config
574    /// joined in from the registry so a single read reflects any `set_config`.
575    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    /// A snapshot of prosperod's operational counters (`active_attaches` is read
587    /// live from the running attach set).
588    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    /// Aggregate readiness: store-writability (the ready gate) plus a summary of
594    /// per-repo health. Used by the `/readyz` endpoint to distinguish liveness
595    /// from readiness.
596    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    /// Replay a stream's history from the store, with `seq >= from_seq`. Callers
615    /// watching a single agent pass the agent id, which is that agent's stream
616    /// key (see [`crate::event::stream_key_for`]); repo/fleet-level history uses
617    /// the `repo:<name>` / `fleet` keys.
618    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    /// Delete persisted events older than `max_age`. Returns the count removed.
623    /// Backs the daemon's age-based retention loop (#4).
624    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    /// Register a workspace and persist the registry. Triggers an immediate poll.
629    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    /// Back-compat alias for [`Self::add_workspace`]: a single-repo workspace.
639    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    /// Back-compat alias for [`Self::add_workspace_with_config`].
644    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    /// Register a workspace with an initial provider config.
654    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        // Same coherence check as the config-set path (#120): a keyless provider
663        // with `api_key_from_env` is rejected at registration rather than silently
664        // ignored at spawn time.
665        crate::provider_env::validate_provider_config(&config)
666            .map_err(CoreError::ProviderMisconfigured)?;
667        // Canonicalize so symlink aliases (e.g. `/tmp` vs `/private/tmp`)
668        // collapse to one root — both for the duplicate-alias guard in the
669        // registry and so the stored root matches the one caliband hashes for
670        // its socket (#45, #47). Best-effort: a not-yet-existing path is kept
671        // as-is rather than rejected.
672        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    /// The stored provider config for a repo, if registered.
700    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    /// Unregister a repo and persist the registry.
710    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            // Persist the removal first; only prune derived state once durable.
717            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    /// Build the `EnsureConfig` for a repo, resolving its env overlay from the
730    /// global default + the repo's stored provider config + prosperod's env.
731    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    /// Update a repo's provider config in the registry only (no restart).
747    pub async fn set_repo_config_registry_only(
748        &self,
749        repo: &str,
750        config: crate::registry::RepoProviderConfig,
751    ) -> Result<()> {
752        // Reject an internally-incoherent config up front (e.g. `api_key_from_env`
753        // on a keyless provider, which would otherwise be silently ignored at
754        // spawn time) so the config-set path surfaces a 400 rather than persisting
755        // a setting that never takes effect (#120).
756        crate::provider_env::validate_provider_config(&config)
757            .map_err(CoreError::ProviderMisconfigured)?;
758        // Hold the registry write lock across the durable upsert so a concurrent
759        // `refresh_registry_from_store` (poll loop) cannot read stale durable
760        // state and clobber this write back. The registry RwLock is async, so
761        // awaiting the config-store write under the guard is sound; config stores
762        // never re-enter the registry lock, so there's no inversion (prospero #85).
763        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    /// Get-or-create the control client for a repo (running discovery once).
776    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        // Network transport (ADR 0051): when configured, dial the caliband over
781        // TCP+TLS+token instead of resolving a local Unix socket. Cached per repo
782        // like the Unix client.
783        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    /// Validate that `repo`'s selected provider has its required credential
808    /// before a spawn is issued, so a misconfigured repo surfaces an actionable
809    /// error to the caller rather than spawning a doomed agent. Resolves the env
810    /// the same way [`Self::ensure_config_for`] does and checks the result.
811    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    /// Launch a new agent under `repo`. Returns the new agent id.
826    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    /// Launch a new agent under `repo`, returning both its id and the
831    /// per-agent endpoint `client.spawn` already handed back — so callers that
832    /// need it (e.g. `LocalFleet::ensure_agent`) don't have to issue a redundant
833    /// `Attach` to re-derive it.
834    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        // Read before `into_spec` consumes the request: the timeout is
842        // prospero's to enforce and never reaches caliband (#221).
843        let req_timeout_secs = req.timeout_secs;
844        let mut spec = req.into_spec();
845        // Select the provider via the wire spec (#93): the caliban worker reads
846        // `SpawnSpec.provider`, not `CALIBAN_PROVIDER`, so carry the repo's
847        // configured provider through. Base URL / API key still flow via the
848        // caliband daemon env (see `provider_env::resolve_env`).
849        spec.provider = self.repo_config(repo).await.and_then(|c| c.provider);
850        let (id, endpoint) = client.spawn(spec).await?;
851        // Record the id→repo mapping before the first poll so a `rm`/`kill`
852        // issued immediately after this returns can still resolve the repo
853        // instead of racing the snapshot to a spurious 404 (#122).
854        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        // #221: record the deadline in the log *before* anything can go wrong
864        // with this replica. A timer here would die with the process; the log
865        // is what a survivor reads back.
866        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    /// Kill an agent (resolving its repo from the snapshot).
883    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    /// Send an inbound control frame to an interactive agent. Rejects if the
889    /// agent is unknown (`AgentNotFound`), terminal, or was not spawned
890    /// interactive (`InvalidState`).
891    ///
892    /// The state gate reads the last poll snapshot (up to one poll interval
893    /// stale); caliband remains authoritative, so a just-terminated agent may
894    /// pass the gate and fail at `attach`/`send_inbound` instead.
895    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    /// Respawn an agent; returns the new id.
927    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    /// Minimal graceful drain (P1): send `EndInput` (`fleet.rs:650`), best-effort
933    /// (the agent may not be interactive, or may already be terminal — either
934    /// way drain still proceeds), then poll [`Self::snapshot`] up to `timeout`
935    /// for the agent to reach a terminal [`AgentStatus`], then unconditionally
936    /// [`Self::kill_agent`]. Full checkpoint-drain is P2/operator territory —
937    /// this just avoids yanking an agent mid-turn when the caller can wait a
938    /// bit.
939    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    /// Observe fleet changes as they happen: an initial burst of `Discovered`
959    /// (one per currently-known agent) and `WorkspaceHealth` (one per repo) built
960    /// from the current [`Self::snapshot`], followed by a live [`FleetChange`]
961    /// feed translated from the bus's `EventKind` diffs — the same ones
962    /// `reconcile` already computes (fleet.rs:811); `reconcile` itself is
963    /// untouched.
964    ///
965    /// Subscribes to the bus (via [`Self::subscribe_all`]) *before* reading the
966    /// snapshot, mirroring [`InProcessBus::subscribe`]'s own eager-registration
967    /// discipline, so no event published in the gap between "read snapshot" and
968    /// "start the live feed" is lost.
969    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                    // A slow local subscriber dropped events (`InProcessBus`
1000                    // only); this fleet-wide view has no seq-replay path of its
1001                    // own, so the gap surfaces only as a log, same posture as
1002                    // other best-effort live consumers (ADR-0004).
1003                    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    /// Remove an agent from caliban's registry.
1018    ///
1019    /// On success the agent is optimistically dropped from the served snapshot
1020    /// so `GET /api/fleet` reflects the removal immediately, rather than
1021    /// continuing to list it for up to one poll interval until the next poll
1022    /// reconciles (#123). The next poll remains authoritative and idempotent.
1023    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        // Drop any spawn-tracking fallback: an agent removed before its first
1028        // poll never appears in `records`, so `reconcile` can't prune it (#122).
1029        self.inner.recent_spawns.lock().unwrap().remove(agent_id);
1030
1031        // Optimistically prune the removed agent from the served snapshot (#123).
1032        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        // Preserve the `AgentGone` the next poll's `reconcile` would have emitted:
1045        // since we pruned the agent from the snapshot baseline, that poll's
1046        // `prior` no longer contains it, so it would otherwise be lost from the
1047        // event log / `watch_changes` feed. Only the repo lifecycle-lease owner
1048        // emits it, matching `reconcile`, so clustered peers don't double-write
1049        // (and a peer that still holds the lease will emit it from its own poll,
1050        // since only this replica's snapshot was pruned). (#59, #123)
1051        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        // Not in the snapshot yet: a just-spawned agent the poll hasn't observed.
1079        // Fall back to the spawn-tracking map so `rm`/`kill` right after `spawn`
1080        // resolve instead of 404ing on the registration race (#122).
1081        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    /// Poll every registered repo once. Refreshes the registry from the shared
1091    /// config store first so a clustered replica picks up repos a peer
1092    /// added/removed/reconfigured between cycles.
1093    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    /// Reload the repo registry from the shared config store (the source of
1105    /// truth) so a clustered replica converges on repos its peers registered or
1106    /// removed. The in-memory registry is replaced wholesale and the snapshot is
1107    /// reconciled — new repos added, removed repos dropped, existing repos keep
1108    /// their health/agents. A read failure leaves the cached view intact. For
1109    /// standalone (single writer) this is a cheap, idempotent no-op. (#50)
1110    async fn refresh_registry_from_store(&self) {
1111        // Read durable state and wholesale-replace the in-memory registry
1112        // atomically under the write lock, so a concurrent
1113        // `set_repo_config_registry_only` can't interleave and get clobbered by a
1114        // stale durable read (prospero #85). Costs one config-store read per poll
1115        // held under the registry lock — sub-ms standalone, a few ms clustered.
1116        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                // New to this replica — the imminent poll sets real health.
1137                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    /// Poll one repo: list agents, reconcile against the snapshot, emit diffs,
1150    /// and start attach tasks for newly-active agents. Failures degrade the
1151    /// repo to `Unreachable` rather than propagating.
1152    pub async fn poll_repo_once(&self, repo: &str) {
1153        self.inner.emitter.metrics.record_repo_poll();
1154        // Designate a single authoritative emitter for this repo's poll-derived
1155        // lifecycle events. The lease keys off the repo's own event stream; in
1156        // standalone (SelfOwnsAll) this is always owned, so behavior is
1157        // unchanged. In clustered mode only the holder emits, so peers don't
1158        // double-write the same transition. (#59)
1159        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                // A failed list usually means the socket died; drop the cached
1177                // client so the next poll re-discovers.
1178                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                // Snapshot health is per-replica; only the lifecycle-lease owner
1195                // writes the transition to the shared log. (#59)
1196                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        // Snapshot prior agent statuses for diffing.
1214        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                // caliband's wire status → prospero's domain status (#239).
1233                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                // A local agent's state is caliband's own report, with no
1239                // admission step above it to refuse the run — so there is never
1240                // an external cause to explain (#241).
1241                reason: None,
1242                detail: None,
1243                // caliband echoes back the spec it was spawned with, so this is
1244                // the posture the agent is really running under (#238).
1245                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                // New to the snapshot. Suppress "discovered" for agents we just
1252                // spawned (already attached + emitted AgentSpawned). Only the
1253                // repo lifecycle-lease owner emits it, so peers don't duplicate
1254                // the observation. (#59)
1255                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                // Only the repo lifecycle-lease owner emits transitions. (#59)
1263                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            // Attach any non-terminal agent (Spawning/Running/Idle), not just
1279            // active ones: an idle interactive agent can resume, and — in
1280            // clustered mode — holding its lease is what lets a survivor replica
1281            // reap the expired lease and fail it over. The lease still gates who
1282            // actually attaches. (#51)
1283            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        // Agents that disappeared from caliban's registry. Only the repo
1290        // lifecycle-lease owner emits it. (#59)
1291        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                    // Snapshot health is per-replica; only the lifecycle-lease
1309                    // owner writes the recovery transition to the shared log. (#59)
1310                    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        // The snapshot is now authoritative for every agent in this poll's
1327        // records, so drop their spawn-tracking fallbacks (#122). Fast
1328        // spawn→rm agents that never appear in a poll are pruned by `rm_agent`.
1329        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        // #221: enforce wall-clock deadlines. Lease-gated like every other
1339        // emission in this loop, so in a cluster exactly one replica acts on an
1340        // expiry rather than all of them racing to kill the same agent.
1341        if own_lifecycle {
1342            self.enforce_deadlines(repo, &client).await;
1343        }
1344    }
1345
1346    /// Kill any agent in `repo` whose deadline has passed (#221).
1347    ///
1348    /// Deadlines this replica did not set are read back from the agent's own
1349    /// history — the failover path, and the reason the deadline is a log entry
1350    /// rather than a timer. The lookup happens once per agent: the miss is
1351    /// cached as "no deadline" by simply not inserting, and an agent with a
1352    /// deadline keeps it in the map afterwards.
1353    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        // Fill in deadlines for live agents this replica has not seen before.
1364        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            // Record the reason first: a `killed` with no explanation reads as
1391            // an operator's doing.
1392            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            // Stop re-firing for an agent already killed; the poll that sees
1404            // its terminal status would skip it anyway, but this avoids a
1405            // second emit in the window before that.
1406            self.inner.deadlines.lock().unwrap().remove(&id);
1407        }
1408    }
1409
1410    /// Start a per-agent attach task if one is not already running. The task
1411    /// reads the agent's stream, normalizes frames into events, and exits when
1412    /// the stream closes.
1413    async fn start_attach(&self, repo: &str, agent_id: &str, client: CalibandClient) {
1414        // Already driving this agent locally? Its attach task holds (and, in
1415        // clustered mode, heartbeats) the lease — leave it untouched.
1416        if self.inner.attached.lock().unwrap().contains(agent_id) {
1417            return;
1418        }
1419        // Claim the stream. Standalone always acquires; clustered consults the
1420        // Postgres lease and returns `None` if another live replica owns it.
1421        // `try_acquire` is idempotent for a stream THIS process already holds.
1422        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                // Lost a race to another start_attach for the same agent. It now
1429                // owns the (idempotently-shared) lease and will release it on
1430                // exit — we must NOT release here or we would orphan its writer.
1431                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                // Local backend: caliband assigned the id prospero uses, so the
1446                // stream key and the caliband attach-id are one and the same.
1447                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            // Release for prompt failover hand-off (clustered); no-op standalone.
1467            attached.ownership.release(&agent_id).await;
1468        });
1469    }
1470
1471    /// Names of repos with a cached control client (test/observability helper).
1472    pub async fn cached_client_names(&self) -> Vec<String> {
1473        self.inner.clients.lock().unwrap().keys().cloned().collect()
1474    }
1475
1476    /// Whether a per-agent attach task is currently registered (test/obs helper).
1477    pub fn is_attached(&self, agent_id: &str) -> bool {
1478        self.inner.attached.lock().unwrap().contains(agent_id)
1479    }
1480
1481    /// Gracefully shut down a repo's caliband daemon and drop its cached client
1482    /// so the next access re-runs discovery (respawning with the current env).
1483    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                // Reuse startup_timeout as the upper bound for the daemon to
1503                // release its socket after Shutdown (a symmetric drain bound).
1504                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    /// Persist a repo's provider config and restart its caliband to apply it.
1521    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    /// Run the background poll loop until [`Self::begin_shutdown`] is signalled.
1531    ///
1532    /// Each iteration runs a *complete* poll cycle (never abandoned mid-append),
1533    /// then waits the interval. A shutdown signal stops scheduling new polls and
1534    /// returns after the in-flight cycle finishes — so the daemon can drain
1535    /// cleanly rather than being killed mid-iteration.
1536    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
1553/// Translate one bus [`FleetEvent`] into the [`FleetChange`] `watch_changes`
1554/// yields, skipping variants that aren't fleet-membership/health diffs (output
1555/// chunks, tool calls, init/finish accounting, etc. stay on the per-agent SSE
1556/// tail, not this fleet-wide view).
1557///
1558/// `AgentDiscovered` carries no payload beyond the stream key (`reconcile`
1559/// emits it as a bare marker, fleet.rs:895) — the full [`Agent`] it names is
1560/// resolved via a bounded, sleep-and-retry lookup against `mgr`'s current
1561/// snapshot. This closes the narrow window between the event reaching the bus
1562/// (inside `reconcile`'s per-record loop) and `reconcile`'s own snapshot write
1563/// landing (after the loop, same poll cycle) without adding a payload to
1564/// `EventKind::AgentDiscovered` or touching `reconcile` itself.
1565///
1566/// The retry is bounded by a **wall-clock deadline**, not a fixed yield count:
1567/// `reconcile` emits this event *before* `.await`ing `emitter.emit(...)`, which
1568/// can itself be a real I/O wait (e.g. `DistributedBus::emit` round-trips to
1569/// Postgres). A yield-count bound (`tokio::task::yield_now()` N times) can
1570/// exhaust in microseconds if the executor keeps rescheduling this task
1571/// eagerly, dropping the `Discovered` change even though the snapshot write
1572/// was only milliseconds away. Sleeping in small increments against a deadline
1573/// gives real I/O the time it needs while still failing safe (log + drop,
1574/// never blocking forever) if the agent genuinely never appears.
1575async 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        // Fleet-membership/health view only; per-agent output/tool/lifecycle
1620        // accounting stays on the per-agent SSE tail (`crate::api::sse`).
1621        EventKind::AgentSpawned
1622        // #221: a deadline and its expiry are per-agent log detail. The fleet
1623        // view learns of the outcome through the `killed` transition that
1624        // follows, which is already a `StatusChanged`.
1625        | 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/// The identity of one attach stream: which caliband agent to `Attach` to, and
1637/// the prospero stream key its events are emitted under.
1638///
1639/// `agent_id` is the **prospero stream key** — events are stored/streamed under
1640/// `stream_key_for(repo, agent_id)`, i.e. what the dashboard's `/stream`
1641/// subscribes to. `attach_id` is the **caliband-side id** named in the
1642/// `Attach { id }` control request. They coincide for the local backend
1643/// (caliband assigned the id prospero also uses). They differ for the k8s
1644/// backend (#159): caliband assigns the id on `Spawn`, but prospero's identity
1645/// is the CR name — so the k8s path attaches to caliband's id while emitting
1646/// under the CR name.
1647#[derive(Clone, Copy)]
1648pub(crate) struct AttachTarget<'a> {
1649    /// Workspace/repo the events belong to.
1650    pub repo: &'a str,
1651    /// Prospero stream key (the CR name in k8s; the caliband id locally).
1652    pub agent_id: &'a str,
1653    /// caliband-assigned id named in the `Attach { id }` request.
1654    pub attach_id: &'a str,
1655}
1656
1657/// How a single attach connection ended.
1658enum StreamOutcome {
1659    /// The agent's terminal `result` frame was seen — the run is done; exit.
1660    Finished,
1661    /// EOF arrived before any terminal frame — a premature drop; reconnect.
1662    Disconnected,
1663}
1664
1665/// Attach to an agent's stream and emit its events, **reconnecting with bounded
1666/// backoff on a premature drop** so transient socket failures don't lose or
1667/// duplicate events.
1668///
1669/// `frames_seen` is a high-water mark over the raw non-empty stream lines: on
1670/// reconnect caliban replays the stream from the start, so we skip the prefix
1671/// we already processed and emit only new frames — no duplicates in the live
1672/// bus or the durable log, and nothing emitted in the gap window is lost (the
1673/// replay carries it). A clean finish (terminal `result` → `AgentFinished`)
1674/// exits without retrying; a drop or read error backs off and reconnects until
1675/// the budget is spent, after which the poll loop remains the re-attach net.
1676///
1677/// `pub(crate)`: `K8sFleet::start_agent_stream` (Task B4, ADR 0008 §3) calls
1678/// this same loop directly over a network `CalibandClient` so a k8s-backed
1679/// agent's session plane lands in the identical bus/store path this module's
1680/// own `start_attach` (Unix-attached, `FleetManager`) uses. `FleetManager`'s
1681/// own call site and behavior are unchanged.
1682///
1683/// The stream key + attach id are carried in [`AttachTarget`]: events are
1684/// emitted under the prospero stream key while the `Attach` request names
1685/// caliband's own id (they coincide locally, differ under k8s — see the type's
1686/// doc).
1687pub(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        // A shutdown was signalled while attached — stop reconnecting and drain.
1715        if *shutdown.borrow() {
1716            return Ok(());
1717        }
1718        // Progress on this connection resets the backoff window.
1719        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
1749/// Read one attach connection to its end, emitting only frames past
1750/// `frames_seen` and advancing it. Returns how the connection ended. A shutdown
1751/// signal stops reading between frames (after any in-flight emit/append), so no
1752/// event is left half-persisted.
1753async 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    // Resolve the per-agent endpoint from caliband's own id (`attach_id`); emit
1767    // under the prospero stream key (`agent_id`). See `AttachTarget`.
1768    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                // Drain: stop reading between frames; the run is being torn down.
1779                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        // Skip the prefix already processed before a reconnect (dedup).
1795        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    // A ConfigStore whose `list_repos` snapshots its state, THEN sleeps (opening
1827    // the exact read-after-write window), returning the pre-sleep snapshot. Lets
1828    // a test deterministically interleave a `set_config` inside a
1829    // `refresh_registry_from_store`'s durable read (prospero #85).
1830    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(); // read BEFORE the delay
1846            tokio::time::sleep(self.read_delay).await; // window for a concurrent set_config
1847            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        // This double exists solely to widen the `list_repos` read window; the
1865        // automation half of the trait is never exercised through it. Panicking
1866        // rather than returning an empty result keeps a future test that *does*
1867        // reach here from quietly passing against a store that holds nothing.
1868        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(); // registry + durable hold r, config {}
1911
1912        // Kick off a poll-style refresh; with the fix it holds the registry lock
1913        // across the slow list_repos, so the set_config below serializes after it.
1914        let m = mgr.clone();
1915        let refresh = tokio::spawn(async move { m.refresh_registry_from_store().await });
1916
1917        // Let refresh get into list_repos, then set the config mid-flight.
1918        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        // The just-set config must survive the concurrent refresh.
1927        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        // The #71 threading seam: a config carrying network materials builds a
1940        // TCP client; without it, the Unix path is unchanged (network_client None).
1941        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        // #6: a template path on the request reaches caliband's SpawnSpec.
1959        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    /// #238: the posture a caller chose has to reach caliband's `SpawnSpec`,
1969    /// and a request that asks for nothing spawns supervised.
1970    #[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        // Each delay sits in [50%, 100%) of the exponential value, capped at max.
1993        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        // Beyond the cap, delays never exceed `max`.
2002        let capped = b.delay_for("agent-a", 20).as_millis() as u64;
2003        assert!((400..=800).contains(&capped), "capped delay {capped}ms");
2004        // Jitter is deterministic per (agent, attempt) — stable across calls.
2005        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; // no real caliband to spawn in tests
2020        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; // cache a client by talking to the repo
2030
2031        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    /// #221: a spawn with a timeout is killed once the deadline passes, and the
2043    /// log says why — a bare `killed` would look like an operator did it.
2044    #[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        // A zero timeout is due the moment it is set — no sleeping in a test.
2062        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        // The deadline is in the durable log, not only in this replica's head:
2067        // that is what lets another replica take the agent over.
2068        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    /// #221 failover: a replica that did not spawn the agent has no deadline in
2091    /// memory, and must read it back from the shared log. A timer could not do
2092    /// this — which is why the deadline is an event.
2093    #[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        // The agent is live on caliband, as it would be after the replica that
2107        // spawned it died.
2108        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        // Its deadline is in the shared store — written by the dead replica.
2116        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        // Terminal agent (Done), even though interactive → reject as terminal.
2159        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        // Idle but NOT interactive → reject.
2163        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        // A FleetManager built with SelfOwnsAll attaches normally: spawning an
2195        // agent records it in the attached set (ownership never refuses).
2196        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        // is_attached is set synchronously in start_attach before tokio::spawn,
2213        // so the check needs no delay.
2214        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        // Ownership that never grants a lease (simulates a peer-owned stream).
2225        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; // no real caliband to spawn in tests
2278        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                // A local openai-compatible endpoint: keyless (base_url set), the
2291                // local-inference migration target (caliban ADR 0056).
2292                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(); // discovery may fail; the registry write is what matters
2320        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    /// A `Store` that fails `append` for a configured set of seqs and otherwise
2338    /// delegates to a real `JsonlStore` — lets a test inject a persist failure
2339    /// for one event while letting the gap marker through.
2340    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        // Interleave two agents; each stream numbers from 1 independently.
2421        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        // Pre-seed the store: stream "a1" already reached seq 5.
2443        {
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        // The new event continues from the stored high-water (5 → 6).
2457        assert_eq!(a1.last().unwrap().seq, 6);
2458    }
2459
2460    #[tokio::test]
2461    async fn emit_retries_on_seq_conflict_instead_of_dropping() {
2462        // A peer replica racing on the same stream takes a seq this emitter also
2463        // computes; the append must re-seed from the durable high-water and
2464        // retry, preserving the event rather than dropping it. Uses the real
2465        // SqliteStore for its `UNIQUE(stream_key, seq)` constraint. (#49)
2466        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        // First emit → seq 1; the emitter caches the stream counter at 1.
2475        emitter.emit("r", "a1", EventKind::AgentSpawned).await;
2476
2477        // A peer writes seq 2 directly to the shared store (high-water → 2).
2478        store.append(&ev(2, "a1", "from-peer")).await.unwrap();
2479
2480        // The emitter's cached counter is still 1, so it computes seq 2 →
2481        // conflict → re-seed (high-water 2) → retry at seq 3 → persisted.
2482        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])); // fail the data event (seq 1)
2512        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        // Live SSE still flows (ADR-0004): the original event reaches the bus...
2530        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        // ...immediately followed by a durable-gap marker naming the lost seq.
2537        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        // The marker is visible to a history reader (persisted), not just logs,
2548        // and the lost event itself is absent — the gap is real but now labeled.
2549        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        // A healthy append must not emit a gap marker: nothing more arrives.
2580        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        // Fail the data event (seq 1); the gap marker (seq 2) appends fine.
2593        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        // run() must drain the in-flight poll and return promptly on the signal,
2619        // rather than looping forever.
2620        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        // Two tasks race the slow-path seed for the same new stream "a1".
2672        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}