Skip to main content

mj_controller/
hel_session_manager.rs

1//! Multiplexed controller-side ownership of durable ACP relay sessions.
2
3use std::collections::{BTreeMap, VecDeque};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9use tokio::sync::{mpsc, oneshot, watch};
10
11use crate::hel_worker_client::{
12    RelayAttachment, RelayClient, RelayEventPage, RelayRejected, RelayTransportDead,
13    StartedReviewer,
14};
15use hel::hel_archive::verify_archive_streaming;
16use hel::hel_credentials::{CredentialSyncSignal, relay_event_credential_sync_reason};
17use hel::hel_database::{
18    ProjectionApplyOutcome, ProjectionIntegrityError, apply_projection_page,
19    save_materialized_session,
20};
21use hel::hel_elicitation::ElicitationResponse;
22use hel::hel_projection::{
23    ProjectionIndex, apply_committed_projection_event_indexed, materialized_session_from_canonical,
24    project_relay_event_indexed,
25};
26use hel::hel_state::{ManagedSessionSnapshot, MaterializedSession};
27use hel::hel_targets::{
28    CancellableProcessExecutor, CommandExecutor, CommandPlan, CommandSpec, TargetLocator,
29    TargetRecoveryOutcome, TargetRecoveryPlan, ensure_recovery_target_running,
30};
31use hel::hel_worker::{RelayCommand, RelayCursor, RelayOperationalState};
32use hel::hel_worker_launch::ReviewerLaunchConfig;
33
34const SESSION_SYNC_INTERVAL: Duration = Duration::from_millis(150);
35/// Release SQLite's single writer between bounded pieces of a large relay
36/// catch-up. One transport page can contain thousands of terminal events and
37/// must not prevent every other session actor from publishing its view.
38const PROJECTION_TRANSACTION_EVENT_BUDGET: usize = 128;
39const RECONNECT_INTERVAL: Duration = Duration::from_secs(1);
40/// Ceiling for reconnect backoff. A worker that exited stays gone until the
41/// user acts, so retrying it every second only burns process spawns.
42const RECONNECT_BACKOFF_CEILING: Duration = Duration::from_secs(30);
43const UNREACHABLE_FAILURE_THRESHOLD: u32 = 2;
44const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(30);
45const WORKER_RESTART_COOLDOWN: Duration = Duration::from_secs(60);
46const SESSION_MANAGER_SHUTDOWN_GRACE: Duration = Duration::from_millis(750);
47
48#[derive(Debug)]
49struct ProjectionAdvancedError {
50    event_ordinal: u64,
51}
52
53impl std::fmt::Display for ProjectionAdvancedError {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(
56            formatter,
57            "another projector committed relay event {} first",
58            self.event_ordinal
59        )
60    }
61}
62
63impl std::error::Error for ProjectionAdvancedError {}
64
65/// Delay before the next reconnect attempt after `failures` consecutive
66/// failures. Doubles from `RECONNECT_INTERVAL` up to the ceiling.
67fn reconnect_delay(failures: u32) -> Duration {
68    let doubling = failures.saturating_sub(1).min(u32::BITS - 1);
69    RECONNECT_INTERVAL
70        .saturating_mul(1_u32 << doubling)
71        .min(RECONNECT_BACKOFF_CEILING)
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct RelaySessionTarget {
76    pub session_id: String,
77    pub spec: CommandSpec,
78    /// Prove the exact worker is absent before restarting it in place. Direct
79    /// relay clients omit recovery; controller-managed sessions self-heal
80    /// without turning a shared transport outage into destructive restarts.
81    pub worker_recovery: Option<WorkerRecoveryPlan>,
82    pub project_memory: Option<ProjectMemorySyncTarget>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ProjectMemorySyncTarget {
87    pub canonical_root: std::path::PathBuf,
88}
89
90/// The working directory a bare-target worker must be able to enter before it
91/// can serve a relay handshake. Container availability is checked separately
92/// by the target recovery plan; bare targets have no runtime object to inspect.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct WorkerWorkspace {
95    pub target: hel::hel_state::ManagedWorktreeTarget,
96    pub directory: PathBuf,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct WorkerRecoveryPlan {
101    pub target: Option<TargetRecoveryPlan>,
102    pub workspace: Option<WorkerWorkspace>,
103    pub liveness_probe: CommandSpec,
104    /// Refresh a stale installed worker before restarting it. The digest is
105    /// computed inside the recovery task so hashing a large binary never
106    /// blocks a controller UI loop.
107    pub binary_refresh: Option<WorkerBinaryRefresh>,
108    /// Keep the worker executable and its launch schema paired. Configuration
109    /// bytes travel through redacted stdin only when their digest is stale.
110    pub launch_refresh: Option<WorkerLaunchRefreshPlan>,
111    pub restart: CommandPlan,
112}
113
114/// How recovery refreshes a stale installed worker binary before restarting.
115///
116/// Local targets resolve the source and the copy at plan-build time, which is
117/// cheap. Remote targets cannot: choosing the binary needs the target's
118/// architecture, and that probe plus hashing the remote binary are blocking
119/// ssh round-trips that must not run on the plan-build/UI path. So a remote
120/// refresh carries only what is cheap to compute and resolves the rest inside
121/// the recovery task.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum WorkerBinaryRefresh {
124    Prepared(WorkerBinaryRefreshPlan),
125    Remote(RemoteWorkerBinaryRefresh),
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct WorkerBinaryRefreshPlan {
130    pub source: PathBuf,
131    pub installed_digest: CommandSpec,
132    pub replace: CommandPlan,
133}
134
135/// A remote worker refresh resolved at recovery time: select the worker binary
136/// for the target's own architecture, compare it to the installed one, and
137/// copy only when they differ.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct RemoteWorkerBinaryRefresh {
140    pub locator: TargetLocator,
141    pub session_id: String,
142    pub installed_digest: CommandSpec,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct WorkerLaunchRefreshPlan {
147    pub expected_sha256: String,
148    pub installed_digest: CommandSpec,
149    pub replace: CommandPlan,
150}
151
152/// Whether a relay failure means the transport to the worker is gone, so
153/// restarting that worker is the only recovery left.
154///
155/// Every failure that proves it is marked with [`RelayTransportDead`] where it
156/// is produced, and this decision downcasts for that marker. Message text is
157/// never read: a reworded diagnostic must not be able to disable auto-restart.
158pub(crate) fn worker_connect_needs_restart(error: &anyhow::Error) -> bool {
159    RelayTransportDead::marks(error)
160}
161
162fn worker_connect_allows_live_restart(error: &anyhow::Error) -> bool {
163    RelayTransportDead::marks_failed_handshake(error)
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167enum WorkerRecoveryOutcome {
168    Alive,
169    Starting,
170    TargetMissing,
171    WorkspaceMissing(PathBuf),
172    RestartedDead,
173    RestartedUnresponsive,
174}
175
176fn refresh_worker_binary_if_stale(
177    executor: &impl CommandExecutor,
178    refresh: Option<&WorkerBinaryRefresh>,
179) -> Result<()> {
180    match refresh {
181        None => Ok(()),
182        Some(WorkerBinaryRefresh::Prepared(plan)) => {
183            let expected = hel::hel_worker_launch::worker_executable_digest(&plan.source)?;
184            if installed_digest_matches(executor, &plan.installed_digest, &expected) {
185                return Ok(());
186            }
187            plan.replace
188                .execute(executor)
189                .context("replace stale relay worker binary")?;
190            Ok(())
191        }
192        // Remote: pick the binary for the target's architecture and copy only
193        // if it differs. Runs here in the recovery task, never on the UI path.
194        Some(WorkerBinaryRefresh::Remote(refresh)) => {
195            crate::hel_controller::refresh_remote_worker_binary_if_stale(executor, refresh)
196        }
197    }
198}
199
200fn installed_digest_matches(
201    executor: &impl CommandExecutor,
202    command: &CommandSpec,
203    expected: &str,
204) -> bool {
205    executor.execute(command).as_ref().is_ok_and(|output| {
206        output.status == 0
207            && String::from_utf8_lossy(&output.stdout)
208                .split_whitespace()
209                .next()
210                .is_some_and(|digest| digest.eq_ignore_ascii_case(expected))
211    })
212}
213
214fn refresh_worker_launch_if_stale(
215    executor: &impl CommandExecutor,
216    plan: Option<&WorkerLaunchRefreshPlan>,
217) -> Result<()> {
218    let Some(plan) = plan else {
219        return Ok(());
220    };
221    if installed_digest_matches(executor, &plan.installed_digest, &plan.expected_sha256) {
222        return Ok(());
223    }
224    plan.replace
225        .execute(executor)
226        .context("replace stale relay worker launch config")?;
227    Ok(())
228}
229
230async fn recover_worker(
231    plan: WorkerRecoveryPlan,
232    restart_unresponsive: bool,
233) -> Result<WorkerRecoveryOutcome> {
234    tokio::task::spawn_blocking(move || {
235        let executor = CancellableProcessExecutor::with_timeout(WORKER_RESTART_TIMEOUT);
236        if ensure_recovery_target_running(&executor, plan.target.as_ref())
237            .context("restore relay worker target")?
238            == TargetRecoveryOutcome::Missing
239        {
240            return Ok(WorkerRecoveryOutcome::TargetMissing);
241        }
242        let output = executor
243            .execute(&plan.liveness_probe)
244            .context("probe relay worker liveness")?;
245        if output.status != 0 {
246            bail!(
247                "{} failed with status {}: {}",
248                plan.liveness_probe.purpose,
249                output.status,
250                String::from_utf8_lossy(&output.stderr).trim()
251            );
252        }
253        match String::from_utf8_lossy(&output.stdout).trim() {
254            "starting" => Ok(WorkerRecoveryOutcome::Starting),
255            "alive" if !restart_unresponsive => Ok(WorkerRecoveryOutcome::Alive),
256            "alive" => {
257                if let Some(workspace) = plan.workspace.as_ref()
258                    && !crate::hel_controller::path_exists_on_managed_target(
259                        &executor,
260                        &workspace.target,
261                        &workspace.directory,
262                    )?
263                {
264                    return Ok(WorkerRecoveryOutcome::WorkspaceMissing(
265                        workspace.directory.clone(),
266                    ));
267                }
268                refresh_worker_binary_if_stale(&executor, plan.binary_refresh.as_ref())?;
269                refresh_worker_launch_if_stale(&executor, plan.launch_refresh.as_ref())?;
270                plan.restart.execute(&executor)?;
271                Ok(WorkerRecoveryOutcome::RestartedUnresponsive)
272            }
273            "dead" => {
274                if let Some(workspace) = plan.workspace.as_ref()
275                    && !crate::hel_controller::path_exists_on_managed_target(
276                        &executor,
277                        &workspace.target,
278                        &workspace.directory,
279                    )?
280                {
281                    return Ok(WorkerRecoveryOutcome::WorkspaceMissing(
282                        workspace.directory.clone(),
283                    ));
284                }
285                refresh_worker_binary_if_stale(&executor, plan.binary_refresh.as_ref())?;
286                refresh_worker_launch_if_stale(&executor, plan.launch_refresh.as_ref())?;
287                plan.restart.execute(&executor)?;
288                Ok(WorkerRecoveryOutcome::RestartedDead)
289            }
290            output => bail!("worker liveness probe returned unexpected output {output:?}"),
291        }
292    })
293    .await
294    .context("worker recovery task failed")?
295}
296
297/// Why a managed session stopped producing fresh views. The kind matters to
298/// callers: an unreachable relay is worth retrying and diagnosing, while a
299/// projection integrity failure is deterministic and needs a different report.
300#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
302pub enum ViewError {
303    Unreachable(String),
304    TargetMissing(String),
305    ProjectionIntegrity(String),
306}
307
308impl ViewError {
309    pub fn detail(&self) -> &str {
310        match self {
311            Self::Unreachable(detail)
312            | Self::TargetMissing(detail)
313            | Self::ProjectionIntegrity(detail) => detail,
314        }
315    }
316}
317
318#[derive(Debug, Clone, PartialEq, Default)]
319pub struct ManagedSessionView {
320    pub snapshot: Option<ManagedSessionSnapshot>,
321    pub connected: bool,
322    pub error: Option<ViewError>,
323}
324
325#[derive(Debug, Clone)]
326pub struct SessionManagerUpdate {
327    pub session_id: String,
328    pub view: ManagedSessionView,
329}
330
331pub struct SessionManagerChannels {
332    pub targets: watch::Sender<Vec<RelaySessionTarget>>,
333    pub control: SessionManagerControl,
334    pub updates: SessionManagerUpdates,
335    pub shutdown: SessionManagerShutdown,
336}
337
338/// Client-side half of a remotely owned session manager.
339///
340/// The daemon remains the only process with relay connections. A control
341/// surface publishes the daemon's latest views here and forwards requests from
342/// [`RemoteSessionRequests`] over its authenticated transport.
343pub struct RemoteSessionManagerChannels {
344    pub targets: watch::Sender<Vec<RelaySessionTarget>>,
345    pub control: SessionManagerControl,
346    pub updates: SessionManagerUpdates,
347    pub shutdown: SessionManagerShutdown,
348    pub publisher: RemoteSessionPublisher,
349    pub requests: RemoteSessionRequests,
350}
351
352#[derive(Clone)]
353pub struct RemoteSessionPublisher {
354    updates: mpsc::UnboundedSender<RemoteManagerUpdate>,
355}
356
357impl RemoteSessionPublisher {
358    pub async fn publish(&self, session_id: String, view: ManagedSessionView) -> Result<()> {
359        self.updates
360            .send(RemoteManagerUpdate::Publish { session_id, view })
361            .context("remote session manager stopped")
362    }
363
364    pub fn try_publish(&self, session_id: String, view: ManagedSessionView) -> Result<()> {
365        self.updates
366            .send(RemoteManagerUpdate::Publish { session_id, view })
367            .context("remote session manager update queue is unavailable")
368    }
369}
370
371pub struct RemoteSessionRequests {
372    requests: mpsc::Receiver<RemoteSessionRequest>,
373}
374
375impl RemoteSessionRequests {
376    pub async fn recv(&mut self) -> Option<RemoteSessionRequest> {
377        self.requests.recv().await
378    }
379}
380
381/// What a caller asks of a session's second-opinion reviewer.
382///
383/// The reviewer is a sidecar of the session's worker, so every action travels
384/// the session's own relay connection rather than opening a second one.
385#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum ReviewerAction {
388    Start {
389        config: Box<ReviewerLaunchConfig>,
390    },
391    Submit {
392        command_id: String,
393        command: RelayCommand,
394    },
395    Attach {
396        after_ordinal: u64,
397        after_digest: String,
398    },
399    Acknowledge {
400        through_ordinal: u64,
401        through_digest: String,
402    },
403    Status,
404    /// Answer a form the reviewer's harness is waiting on. A reviewer left
405    /// waiting on one stalls the whole review.
406    RespondElicitation {
407        elicitation_id: String,
408        response: ElicitationResponse,
409    },
410    Pause,
411    /// Report what the workspace repositories changed since these baselines.
412    CaptureDelta {
413        baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
414    },
415    /// Record the trees a completed review reviewed through.
416    AdvanceBaseline {
417        trees: std::collections::BTreeMap<std::path::PathBuf, String>,
418    },
419    /// Run Bifrost's semantic diff analysis over the captured trees.
420    AnalyzeDelta {
421        repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
422    },
423    /// Collect the specialist lanes the review supervisor asked for.
424    TakeLaneDispatches,
425}
426
427impl ReviewerAction {
428    pub const fn operation_name(&self) -> &'static str {
429        match self {
430            Self::Start { .. } => "reviewer_start",
431            Self::Submit { .. } => "reviewer_submit",
432            Self::Attach { .. } => "reviewer_attach",
433            Self::Acknowledge { .. } => "reviewer_acknowledge",
434            Self::Status => "reviewer_status",
435            Self::RespondElicitation { .. } => "reviewer_respond_elicitation",
436            Self::Pause => "reviewer_pause",
437            Self::CaptureDelta { .. } => "reviewer_capture_delta",
438            Self::AdvanceBaseline { .. } => "reviewer_advance_baseline",
439            Self::AnalyzeDelta { .. } => "reviewer_analyze_delta",
440            Self::TakeLaneDispatches => "reviewer_take_lane_dispatches",
441        }
442    }
443}
444
445/// What a [`ReviewerAction`] produced.
446#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum ReviewerOutcome {
449    Started(Box<StartedReviewer>),
450    Accepted {
451        ordinal: u64,
452    },
453    Attached(Box<RelayAttachment>),
454    Acknowledged(RelayCursor),
455    Status(Box<RelayOperationalState>),
456    ElicitationResolved,
457    Paused,
458    /// What every workspace repository changed since the stored baselines.
459    Delta {
460        repositories: Vec<hel::hel_worker::RepoDelta>,
461    },
462    BaselineAdvanced,
463    /// Bifrost's changed-callable packet for the captured trees.
464    ChangedFunctions {
465        packet: String,
466    },
467    /// Specialist lanes the review supervisor asked for.
468    LaneDispatches {
469        requests: Vec<hel::hel_review::lanes::ReviewSubagentRequest>,
470    },
471}
472
473pub enum RemoteSessionRequest {
474    Submit {
475        session_id: String,
476        command_id: String,
477        command: RelayCommand,
478        admission: Option<ReviewDeliveryAdmission>,
479        reply: oneshot::Sender<std::result::Result<u64, String>>,
480    },
481    Sync {
482        session_id: String,
483        reply: oneshot::Sender<std::result::Result<(), String>>,
484    },
485    RespondElicitation {
486        session_id: String,
487        elicitation_id: String,
488        response: ElicitationResponse,
489        reply: oneshot::Sender<std::result::Result<(), String>>,
490    },
491    Reviewer {
492        session_id: String,
493        /// Which reviewing role the action drives; `None` is the default one.
494        role: Option<String>,
495        action: ReviewerAction,
496        reply: oneshot::Sender<std::result::Result<ReviewerOutcome, String>>,
497    },
498}
499
500impl RemoteSessionRequest {
501    /// The session this request acts on. Requests for one session have to be
502    /// carried out in the order they were made.
503    pub fn session_id(&self) -> &str {
504        match self {
505            Self::Submit { session_id, .. }
506            | Self::Sync { session_id, .. }
507            | Self::RespondElicitation { session_id, .. }
508            | Self::Reviewer { session_id, .. } => session_id,
509        }
510    }
511}
512
513/// Keeps each session's relay requests in the order they were made, while
514/// letting different sessions overlap.
515///
516/// A bridge that spawns every request concurrently loses the order the caller
517/// submitted them in, and the order is load-bearing: `/effort` followed by a
518/// prompt has to reach the relay that way round, or the prompt runs under the
519/// old setting. Awaiting each request inline would restore the order but would
520/// also make one slow session block every other one, so instead each request
521/// waits on its own session's previous request and nothing else.
522#[derive(Default)]
523pub struct SessionRequestOrder {
524    latest: std::collections::HashMap<SessionRequestStream, tokio::task::JoinHandle<()>>,
525}
526
527#[derive(Debug, PartialEq, Eq, Hash)]
528enum SessionRequestStream {
529    Primary(String),
530    Reviewer(String, Option<String>),
531}
532
533impl SessionRequestOrder {
534    #[must_use]
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Runs `forward` for `request` after everything already queued for the
540    /// same primary or reviewer role has finished. Independent reviewers
541    /// must not delay primary controls or one another.
542    pub fn dispatch<F, Fut>(&mut self, request: RemoteSessionRequest, forward: F)
543    where
544        F: FnOnce(RemoteSessionRequest) -> Fut + Send + 'static,
545        Fut: std::future::Future<Output = ()> + Send,
546    {
547        // Sessions that have gone quiet leave a finished handle behind; drop
548        // them here so the map tracks live work rather than every session the
549        // bridge has ever served.
550        self.latest.retain(|_, handle| !handle.is_finished());
551        let stream = match &request {
552            RemoteSessionRequest::Reviewer {
553                session_id, role, ..
554            } => SessionRequestStream::Reviewer(session_id.clone(), role.clone()),
555            _ => SessionRequestStream::Primary(request.session_id().to_owned()),
556        };
557        let previous = self.latest.remove(&stream);
558        let handle = tokio::spawn(async move {
559            if let Some(previous) = previous {
560                // A panicked predecessor still releases its successor: the
561                // request behind it is the user's, and dropping it silently
562                // would be worse than running it late.
563                if let Err(error) = previous.await {
564                    tracing::error!(%error, "previous session request task failed");
565                }
566            }
567            forward(request).await;
568        });
569        self.latest.insert(stream, handle);
570    }
571}
572
573/// Exclusive owner of the manager task and every relay actor below it.
574///
575/// Long-running control surfaces explicitly await [`Self::shutdown`] before
576/// their Tokio runtime goes away. Drop remains an aborting fallback for tests
577/// and early-return paths that cannot await.
578pub struct SessionManagerShutdown {
579    signal: Option<oneshot::Sender<()>>,
580    task: Option<tokio::task::JoinHandle<()>>,
581}
582
583impl SessionManagerShutdown {
584    pub async fn shutdown(mut self) -> Result<()> {
585        if let Some(signal) = self.signal.take() {
586            let _ = signal.send(());
587        }
588        if let Some(task) = self.task.take() {
589            task.await.context("session manager shutdown task failed")?;
590        }
591        Ok(())
592    }
593}
594
595impl Drop for SessionManagerShutdown {
596    fn drop(&mut self) {
597        if let Some(signal) = self.signal.take() {
598            let _ = signal.send(());
599        }
600        if let Some(task) = self.task.take() {
601            task.abort();
602        }
603    }
604}
605
606#[derive(Clone)]
607struct CoalescedUpdateSender {
608    pending: Arc<Mutex<BTreeMap<String, SessionManagerUpdate>>>,
609    wake: mpsc::Sender<()>,
610}
611
612/// Bounded latest-state feed for the dashboard. At most one snapshot per
613/// session is retained while the consumer is busy.
614pub struct SessionManagerUpdates {
615    pending: Arc<Mutex<BTreeMap<String, SessionManagerUpdate>>>,
616    wake: mpsc::Receiver<()>,
617}
618
619impl CoalescedUpdateSender {
620    fn send(&self, update: SessionManagerUpdate) {
621        if self.wake.is_closed() {
622            return;
623        }
624        self.pending
625            .lock()
626            .expect("session update coalescer poisoned")
627            .insert(update.session_id.clone(), update);
628        let _ = self.wake.try_send(());
629    }
630}
631
632impl SessionManagerUpdates {
633    fn pop_pending(&self) -> Option<SessionManagerUpdate> {
634        self.pending
635            .lock()
636            .expect("session update coalescer poisoned")
637            .pop_first()
638            .map(|(_, update)| update)
639    }
640
641    pub async fn recv(&mut self) -> Option<SessionManagerUpdate> {
642        loop {
643            if let Some(update) = self.pop_pending() {
644                return Some(update);
645            }
646            self.wake.recv().await?;
647        }
648    }
649
650    pub fn try_recv(
651        &mut self,
652    ) -> std::result::Result<SessionManagerUpdate, mpsc::error::TryRecvError> {
653        if let Some(update) = self.pop_pending() {
654            return Ok(update);
655        }
656        self.wake.try_recv()?;
657        self.pop_pending().ok_or(mpsc::error::TryRecvError::Empty)
658    }
659}
660
661fn coalesced_update_channel() -> (CoalescedUpdateSender, SessionManagerUpdates) {
662    let pending = Arc::new(Mutex::new(BTreeMap::new()));
663    let (wake_tx, wake_rx) = mpsc::channel(1);
664    (
665        CoalescedUpdateSender {
666            pending: pending.clone(),
667            wake: wake_tx,
668        },
669        SessionManagerUpdates {
670            pending,
671            wake: wake_rx,
672        },
673    )
674}
675
676#[derive(Clone)]
677pub struct SessionManagerControl {
678    commands: mpsc::Sender<ManagerCommand>,
679}
680
681#[derive(Clone, Debug)]
682pub struct ManagedSessionHandle {
683    session_id: String,
684    commands: mpsc::Sender<ActorCommand>,
685    releases: mpsc::UnboundedSender<ReturnedConnection>,
686    view: watch::Receiver<ManagedSessionView>,
687}
688
689/// A one-command capability issued by the review host while its prompt hold
690/// is open. It is intentionally opaque to callers: the session actor checks
691/// it against the host's live hold registry before bypassing prompt refusal.
692#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct ReviewDeliveryAdmission {
694    session_id: String,
695    epoch: u64,
696    command_id: String,
697}
698
699impl ReviewDeliveryAdmission {
700    pub(crate) fn new(session_id: String, epoch: u64, command_id: String) -> Self {
701        Self {
702            session_id,
703            epoch,
704            command_id,
705        }
706    }
707
708    pub(crate) fn session_id(&self) -> &str {
709        &self.session_id
710    }
711
712    pub(crate) const fn epoch(&self) -> u64 {
713        self.epoch
714    }
715
716    pub(crate) fn command_id(&self) -> &str {
717        &self.command_id
718    }
719}
720
721/// Exclusive ownership of a session actor's existing relay connection.
722///
723/// Lifecycle operations use this instead of opening a competing projection
724/// client. Dropping an unreleased lease drops the proxy connection, which in
725/// turn cancels any ordinary relay checkpoint barrier.
726///
727/// Prompt submissions that arrive while the lease is active are not rejected.
728/// The actor queues them and forwards them in arrival order once the lease is
729/// released or dropped.
730pub struct ManagedSessionLease {
731    session_id: String,
732    lease_id: Option<u64>,
733    connection: Option<StandaloneSession>,
734    releases: mpsc::UnboundedSender<ReturnedConnection>,
735}
736
737impl ManagedSessionLease {
738    pub fn connection_mut(&mut self) -> &mut StandaloneSession {
739        self.connection
740            .as_mut()
741            .expect("managed session lease has already been released")
742    }
743
744    /// Swap the leased proxy after the worker process behind it was replaced.
745    /// The actor stays leased, so queued prompts cannot race the new latch.
746    pub fn replace_connection(&mut self, connection: StandaloneSession) {
747        drop(self.connection.take());
748        self.connection = Some(connection);
749    }
750
751    pub fn release(mut self) {
752        let lease_id = self
753            .lease_id
754            .take()
755            .expect("managed session lease has already been released");
756        let connection = self.connection.take();
757        if let Err(error) = self.releases.send(ReturnedConnection {
758            lease_id,
759            connection,
760        }) {
761            tracing::warn!(
762                session_id = %self.session_id,
763                operation = "lease_release",
764                %error,
765                "session actor stopped before receiving released relay connection"
766            );
767        }
768    }
769}
770
771impl Drop for ManagedSessionLease {
772    fn drop(&mut self) {
773        let Some(lease_id) = self.lease_id.take() else {
774            return;
775        };
776        // Drop the proxy before telling the actor to reconnect so the relay
777        // observes EOF and releases any abandoned checkpoint barrier first.
778        drop(self.connection.take());
779        if let Err(error) = self.releases.send(ReturnedConnection {
780            lease_id,
781            connection: None,
782        }) {
783            tracing::warn!(
784                session_id = %self.session_id,
785                operation = "lease_drop",
786                %error,
787                "session actor stopped before receiving dropped relay lease"
788            );
789        }
790    }
791}
792
793impl ManagedSessionHandle {
794    pub fn session_id(&self) -> &str {
795        &self.session_id
796    }
797
798    pub fn view(&self) -> ManagedSessionView {
799        self.view.borrow().clone()
800    }
801
802    /// Whether the per-session actor behind this handle has retired. The
803    /// manager itself may still be alive with a replacement actor, so callers
804    /// holding long-lived handles use this to reacquire the current one.
805    pub fn is_stopped(&self) -> bool {
806        self.commands.is_closed()
807    }
808
809    pub fn has_changed(&self) -> Result<bool> {
810        self.view.has_changed().context("session manager stopped")
811    }
812
813    pub async fn changed(&mut self) -> Result<ManagedSessionView> {
814        self.view
815            .changed()
816            .await
817            .context("session manager stopped")?;
818        Ok(self.view())
819    }
820
821    pub async fn submit(&self, command_id: String, command: RelayCommand) -> Result<u64> {
822        self.enqueue_submit(command_id, command).await?.wait().await
823    }
824
825    /// Submit the review's corrective prompt through the one admission that
826    /// corresponds to its live prompt hold. Generic submissions continue to
827    /// use [`Self::submit`] and remain subject to review refusal.
828    pub(crate) async fn submit_review_delivery(
829        &self,
830        admission: ReviewDeliveryAdmission,
831        command: RelayCommand,
832    ) -> Result<u64> {
833        let command_id = admission.command_id.clone();
834        self.enqueue_submit_with_admission(command_id, command, Some(admission))
835            .await?
836            .wait()
837            .await
838    }
839
840    pub async fn enqueue_submit(
841        &self,
842        command_id: String,
843        command: RelayCommand,
844    ) -> Result<PendingRelaySubmit> {
845        self.enqueue_submit_with_admission(command_id, command, None)
846            .await
847    }
848
849    async fn enqueue_submit_with_admission(
850        &self,
851        command_id: String,
852        command: RelayCommand,
853        admission: Option<ReviewDeliveryAdmission>,
854    ) -> Result<PendingRelaySubmit> {
855        let (reply, response) = oneshot::channel();
856        self.commands
857            .send(ActorCommand::Submit {
858                command_id,
859                command,
860                admission,
861                reply,
862            })
863            .await
864            .context("session manager stopped")?;
865        Ok(PendingRelaySubmit { response })
866    }
867
868    pub async fn sync_now(&self) -> Result<()> {
869        self.enqueue_sync().await?.wait().await
870    }
871
872    pub async fn respond_elicitation(
873        &self,
874        elicitation_id: String,
875        response: ElicitationResponse,
876    ) -> Result<()> {
877        let (reply, result) = oneshot::channel();
878        self.commands
879            .send(ActorCommand::RespondElicitation {
880                elicitation_id,
881                response,
882                reply,
883            })
884            .await
885            .context("session manager stopped")?;
886        result
887            .await
888            .context("session manager stopped")?
889            .map_err(anyhow::Error::msg)
890    }
891
892    /// Drive the session's second-opinion reviewer.
893    ///
894    /// The reviewer shares this session's relay connection, so its actions
895    /// queue behind the session's own and are refused while a lifecycle
896    /// operation holds the connection.
897    pub async fn reviewer(&self, action: ReviewerAction) -> Result<ReviewerOutcome> {
898        self.reviewer_as(None, action).await
899    }
900
901    /// Drive one reviewing role. `None` is the default role, which is the one
902    /// plan review uses; a turn review in the extended tier names its
903    /// supervisor, its intent analyst, and each specialist lane.
904    pub async fn reviewer_as(
905        &self,
906        role: Option<String>,
907        action: ReviewerAction,
908    ) -> Result<ReviewerOutcome> {
909        let (reply, result) = oneshot::channel();
910        self.commands
911            .send(ActorCommand::Reviewer {
912                role,
913                action,
914                reply,
915            })
916            .await
917            .context("session manager stopped")?;
918        result
919            .await
920            .context("session manager stopped")?
921            .map_err(anyhow::Error::msg)
922    }
923
924    pub async fn enqueue_sync(&self) -> Result<PendingRelaySync> {
925        let (reply, response) = oneshot::channel();
926        self.commands
927            .send(ActorCommand::Sync { reply })
928            .await
929            .context("session manager stopped")?;
930        Ok(PendingRelaySync { response })
931    }
932
933    pub async fn lease_connection(&self) -> Result<ManagedSessionLease> {
934        let (reply, response) = oneshot::channel();
935        self.commands
936            .send(ActorCommand::Lease { reply })
937            .await
938            .context("session manager stopped")?;
939        let (lease_id, connection) = response.await.context("session manager stopped")??;
940        Ok(ManagedSessionLease {
941            session_id: self.session_id.clone(),
942            lease_id: Some(lease_id),
943            connection: Some(connection),
944            releases: self.releases.clone(),
945        })
946    }
947}
948
949pub struct PendingRelaySubmit {
950    response: oneshot::Receiver<std::result::Result<u64, String>>,
951}
952
953impl PendingRelaySubmit {
954    pub async fn wait(self) -> Result<u64> {
955        self.response
956            .await
957            .context("session manager stopped")?
958            .map_err(anyhow::Error::msg)
959    }
960}
961
962pub struct PendingRelaySync {
963    response: oneshot::Receiver<std::result::Result<(), String>>,
964}
965
966impl PendingRelaySync {
967    pub async fn wait(self) -> Result<()> {
968        self.response
969            .await
970            .context("session manager stopped")?
971            .map_err(anyhow::Error::msg)
972    }
973}
974
975impl SessionManagerControl {
976    pub async fn session(&self, session_id: impl Into<String>) -> Result<ManagedSessionHandle> {
977        let session_id = session_id.into();
978        let (reply, response) = oneshot::channel();
979        self.commands
980            .send(ManagerCommand::Session {
981                session_id: session_id.clone(),
982                reply,
983            })
984            .await
985            .context("session manager stopped")?;
986        response
987            .await
988            .context("session manager stopped")?
989            .with_context(|| format!("session {session_id} is not managed"))
990    }
991
992    pub async fn wait_for_session(
993        &self,
994        session_id: &str,
995        timeout: Duration,
996    ) -> Result<ManagedSessionHandle> {
997        tokio::time::timeout(timeout, async {
998            loop {
999                match self.session(session_id.to_owned()).await {
1000                    Ok(handle) => return Ok(handle),
1001                    Err(error) => {
1002                        tracing::trace!(session_id, "waiting for session actor: {error:#}");
1003                        tokio::time::sleep(Duration::from_millis(25)).await;
1004                    }
1005                }
1006            }
1007        })
1008        .await
1009        .with_context(|| {
1010            format!(
1011                "session {session_id} did not become available within {} seconds",
1012                timeout.as_secs()
1013            )
1014        })?
1015    }
1016}
1017
1018enum ManagerCommand {
1019    Session {
1020        session_id: String,
1021        reply: oneshot::Sender<Option<ManagedSessionHandle>>,
1022    },
1023}
1024
1025enum ActorCommand {
1026    Submit {
1027        command_id: String,
1028        command: RelayCommand,
1029        admission: Option<ReviewDeliveryAdmission>,
1030        reply: oneshot::Sender<std::result::Result<u64, String>>,
1031    },
1032    Sync {
1033        reply: oneshot::Sender<std::result::Result<(), String>>,
1034    },
1035    RespondElicitation {
1036        elicitation_id: String,
1037        response: ElicitationResponse,
1038        reply: oneshot::Sender<std::result::Result<(), String>>,
1039    },
1040    Reviewer {
1041        role: Option<String>,
1042        action: ReviewerAction,
1043        reply: oneshot::Sender<std::result::Result<ReviewerOutcome, String>>,
1044    },
1045    /// The connection is handed over whole, and so is the failure: a caller
1046    /// that must decide whether to restart the worker needs the typed cause,
1047    /// which formatting the error to a string would destroy.
1048    Lease {
1049        reply: oneshot::Sender<Result<(u64, StandaloneSession)>>,
1050    },
1051}
1052
1053impl ActorCommand {
1054    fn operation_name(&self) -> &'static str {
1055        match self {
1056            Self::Submit { .. } => "submit",
1057            Self::Sync { .. } => "sync",
1058            Self::RespondElicitation { .. } => "respond_elicitation",
1059            Self::Reviewer { action, .. } => action.operation_name(),
1060            Self::Lease { .. } => "lease",
1061        }
1062    }
1063
1064    fn reject(self, session_id: &str, message: &str) {
1065        match self {
1066            Self::Submit { reply, .. } => {
1067                if reply.send(Err(message.to_owned())).is_err() {
1068                    tracing::debug!(
1069                        %session_id,
1070                        operation = "submit",
1071                        "submit rejection receiver was already closed"
1072                    );
1073                }
1074            }
1075            Self::Sync { reply } => {
1076                if reply.send(Err(message.to_owned())).is_err() {
1077                    tracing::debug!(
1078                        %session_id,
1079                        operation = "sync",
1080                        "sync rejection receiver was already closed"
1081                    );
1082                }
1083            }
1084            Self::RespondElicitation { reply, .. } => {
1085                if reply.send(Err(message.to_owned())).is_err() {
1086                    tracing::debug!(
1087                        %session_id,
1088                        operation = "respond_elicitation",
1089                        "elicitation rejection receiver was already closed"
1090                    );
1091                }
1092            }
1093            Self::Reviewer { reply, .. } => {
1094                if reply.send(Err(message.to_owned())).is_err() {
1095                    tracing::debug!(
1096                        %session_id,
1097                        operation = "reviewer",
1098                        "reviewer rejection receiver was already closed"
1099                    );
1100                }
1101            }
1102            Self::Lease { reply } => {
1103                if reply
1104                    .send(Err(anyhow::anyhow!(message.to_owned())))
1105                    .is_err()
1106                {
1107                    tracing::debug!(
1108                        %session_id,
1109                        operation = "lease",
1110                        "lease rejection receiver was already closed"
1111                    );
1112                }
1113            }
1114        }
1115    }
1116}
1117
1118struct ReturnedConnection {
1119    lease_id: u64,
1120    connection: Option<StandaloneSession>,
1121}
1122
1123/// A submission that arrived while a lifecycle operation held the connection.
1124/// The actor replays these in arrival order once the lease comes back.
1125struct DeferredSubmit {
1126    command_id: String,
1127    command: RelayCommand,
1128    admission: Option<ReviewDeliveryAdmission>,
1129    reply: oneshot::Sender<std::result::Result<u64, String>>,
1130}
1131
1132#[derive(Debug, Default)]
1133struct ActorLifecycle {
1134    active_lease: Option<u64>,
1135    retirement_requested: bool,
1136}
1137
1138impl ActorLifecycle {
1139    fn set_retirement_requested(&mut self, requested: bool) {
1140        self.retirement_requested = requested;
1141    }
1142
1143    fn is_leased(&self) -> bool {
1144        self.active_lease.is_some()
1145    }
1146
1147    fn should_stop(&self) -> bool {
1148        self.retirement_requested && !self.is_leased()
1149    }
1150
1151    fn accepts_new_work(&self) -> bool {
1152        !self.retirement_requested
1153    }
1154
1155    fn activate_lease(&mut self, lease_id: u64) {
1156        debug_assert!(self.active_lease.is_none());
1157        self.active_lease = Some(lease_id);
1158    }
1159
1160    fn return_lease(&mut self, lease_id: u64) -> bool {
1161        if self.active_lease != Some(lease_id) {
1162            return false;
1163        }
1164        self.active_lease = None;
1165        true
1166    }
1167}
1168
1169struct ActorRegistration {
1170    target: RelaySessionTarget,
1171    commands: mpsc::Sender<ActorCommand>,
1172    releases: mpsc::UnboundedSender<ReturnedConnection>,
1173    retirement: watch::Sender<bool>,
1174    view: watch::Receiver<ManagedSessionView>,
1175    abort: tokio::task::AbortHandle,
1176}
1177
1178struct RemoteActorRegistration {
1179    commands: mpsc::Sender<ActorCommand>,
1180    releases: mpsc::UnboundedSender<ReturnedConnection>,
1181    view: watch::Receiver<ManagedSessionView>,
1182    view_tx: watch::Sender<ManagedSessionView>,
1183    abort: tokio::task::AbortHandle,
1184}
1185
1186enum RemoteManagerUpdate {
1187    Publish {
1188        session_id: String,
1189        view: ManagedSessionView,
1190    },
1191}
1192
1193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1194enum ReconcileAction {
1195    Idle,
1196    Spawn,
1197    Keep,
1198    Retire,
1199}
1200
1201fn reconcile_action(
1202    actor: Option<&RelaySessionTarget>,
1203    desired: Option<&RelaySessionTarget>,
1204) -> ReconcileAction {
1205    match (actor, desired) {
1206        (None, None) => ReconcileAction::Idle,
1207        (None, Some(_)) => ReconcileAction::Spawn,
1208        (Some(actor), Some(desired)) if actor == desired => ReconcileAction::Keep,
1209        (Some(_), Some(_) | None) => ReconcileAction::Retire,
1210    }
1211}
1212
1213fn target_map(targets: &[RelaySessionTarget]) -> BTreeMap<String, RelaySessionTarget> {
1214    targets
1215        .iter()
1216        .cloned()
1217        .map(|target| (target.session_id.clone(), target))
1218        .collect()
1219}
1220
1221fn remove_actor_task(
1222    actors: &mut BTreeMap<String, ActorRegistration>,
1223    task_id: tokio::task::Id,
1224) -> Option<String> {
1225    let session_id = actors.iter().find_map(|(session_id, actor)| {
1226        (actor.abort.id() == task_id).then(|| session_id.clone())
1227    })?;
1228    actors.remove(&session_id);
1229    Some(session_id)
1230}
1231
1232fn reconcile_actors(
1233    targets: &BTreeMap<String, RelaySessionTarget>,
1234    actors: &mut BTreeMap<String, ActorRegistration>,
1235    tasks: &mut tokio::task::JoinSet<String>,
1236    updates: &CoalescedUpdateSender,
1237) {
1238    // A completed or cancelled task closes its command receiver before the
1239    // JoinSet completion necessarily wins the manager's select. Do not let
1240    // that dead registration suppress the replacement this reconciliation is
1241    // responsible for starting. Task-ID-aware completion cleanup below keeps
1242    // the old completion from removing the replacement later.
1243    actors.retain(|session_id, actor| {
1244        let live = !actor.commands.is_closed();
1245        if !live {
1246            tracing::warn!(session_id, "replacing stopped session relay actor");
1247        }
1248        live
1249    });
1250
1251    for (session_id, actor) in actors.iter() {
1252        let retiring = matches!(
1253            reconcile_action(Some(&actor.target), targets.get(session_id)),
1254            ReconcileAction::Retire
1255        );
1256        actor.retirement.send_replace(retiring);
1257    }
1258
1259    for (session_id, target) in targets {
1260        if !matches!(
1261            reconcile_action(
1262                actors.get(session_id).map(|actor| &actor.target),
1263                Some(target)
1264            ),
1265            ReconcileAction::Spawn
1266        ) {
1267            continue;
1268        }
1269        let (actor_tx, actor_rx) = mpsc::channel(32);
1270        let (release_tx, release_rx) = mpsc::unbounded_channel();
1271        let (retirement_tx, retirement_rx) = watch::channel(false);
1272        let (view_tx, view_rx) = watch::channel(ManagedSessionView::default());
1273        let actor_updates = updates.clone();
1274        let task_target = target.clone();
1275        let task_id = session_id.clone();
1276        let abort = tasks.spawn(async move {
1277            run_session_actor(
1278                task_target,
1279                actor_rx,
1280                release_rx,
1281                retirement_rx,
1282                view_tx,
1283                actor_updates,
1284            )
1285            .await;
1286            task_id
1287        });
1288        actors.insert(
1289            session_id.clone(),
1290            ActorRegistration {
1291                target: target.clone(),
1292                commands: actor_tx,
1293                releases: release_tx,
1294                retirement: retirement_tx,
1295                view: view_rx,
1296                abort,
1297            },
1298        );
1299    }
1300}
1301
1302async fn run_remote_session_actor(
1303    session_id: String,
1304    mut commands: mpsc::Receiver<ActorCommand>,
1305    requests: mpsc::Sender<RemoteSessionRequest>,
1306) {
1307    while let Some(command) = commands.recv().await {
1308        let request = match command {
1309            ActorCommand::Submit {
1310                command_id,
1311                command,
1312                admission,
1313                reply,
1314            } => RemoteSessionRequest::Submit {
1315                session_id: session_id.clone(),
1316                command_id,
1317                command,
1318                admission,
1319                reply,
1320            },
1321            ActorCommand::Sync { reply } => RemoteSessionRequest::Sync {
1322                session_id: session_id.clone(),
1323                reply,
1324            },
1325            ActorCommand::RespondElicitation {
1326                elicitation_id,
1327                response,
1328                reply,
1329            } => RemoteSessionRequest::RespondElicitation {
1330                session_id: session_id.clone(),
1331                elicitation_id,
1332                response,
1333                reply,
1334            },
1335            ActorCommand::Reviewer {
1336                role,
1337                action,
1338                reply,
1339            } => RemoteSessionRequest::Reviewer {
1340                session_id: session_id.clone(),
1341                role,
1342                action,
1343                reply,
1344            },
1345            ActorCommand::Lease { reply } => {
1346                let _ = reply.send(Err(anyhow::anyhow!(
1347                    "relay connection leases are available only inside the controller daemon"
1348                )));
1349                continue;
1350            }
1351        };
1352        if let Err(error) = requests.send(request).await {
1353            match error.0 {
1354                RemoteSessionRequest::Submit { reply, .. } => {
1355                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
1356                }
1357                RemoteSessionRequest::Sync { reply, .. }
1358                | RemoteSessionRequest::RespondElicitation { reply, .. } => {
1359                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
1360                }
1361                RemoteSessionRequest::Reviewer { reply, .. } => {
1362                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
1363                }
1364            }
1365            break;
1366        }
1367    }
1368}
1369
1370fn spawn_remote_actor(
1371    session_id: String,
1372    view: ManagedSessionView,
1373    requests: &mpsc::Sender<RemoteSessionRequest>,
1374    actors: &mut BTreeMap<String, RemoteActorRegistration>,
1375    updates: &CoalescedUpdateSender,
1376) {
1377    let (actor_tx, actor_rx) = mpsc::channel(32);
1378    let (release_tx, _release_rx) = mpsc::unbounded_channel();
1379    let (view_tx, view_rx) = watch::channel(view.clone());
1380    let abort = tokio::spawn(run_remote_session_actor(
1381        session_id.clone(),
1382        actor_rx,
1383        requests.clone(),
1384    ))
1385    .abort_handle();
1386    actors.insert(
1387        session_id.clone(),
1388        RemoteActorRegistration {
1389            commands: actor_tx,
1390            releases: release_tx,
1391            view: view_rx,
1392            view_tx,
1393            abort,
1394        },
1395    );
1396    updates.send(SessionManagerUpdate { session_id, view });
1397}
1398
1399/// Build the read/control facade used by a control surface whose relay actors
1400/// live in another process. Target updates still decide which session handles
1401/// exist, while [`RemoteSessionPublisher`] supplies their latest views.
1402pub fn spawn_remote_session_manager() -> Result<RemoteSessionManagerChannels> {
1403    let (targets_tx, mut targets_rx) = watch::channel(Vec::<RelaySessionTarget>::new());
1404    let (commands_tx, mut commands_rx) = mpsc::channel(32);
1405    let (updates_tx, updates_rx) = coalesced_update_channel();
1406    let (published_tx, mut published_rx) = mpsc::unbounded_channel();
1407    let (requests_tx, requests_rx) = mpsc::channel(64);
1408    let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
1409    let task = tokio::spawn(async move {
1410        let mut actors = BTreeMap::<String, RemoteActorRegistration>::new();
1411        let mut latest = BTreeMap::<String, ManagedSessionView>::new();
1412        let mut desired = BTreeMap::<String, RelaySessionTarget>::new();
1413        loop {
1414            tokio::select! {
1415                _ = &mut shutdown_rx => break,
1416                changed = targets_rx.changed() => {
1417                    if changed.is_err() {
1418                        break;
1419                    }
1420                    desired = target_map(&targets_rx.borrow_and_update());
1421                    actors.retain(|session_id, actor| {
1422                        if desired.contains_key(session_id) {
1423                            true
1424                        } else {
1425                            actor.abort.abort();
1426                            false
1427                        }
1428                    });
1429                    for session_id in desired.keys() {
1430                        if !actors.contains_key(session_id)
1431                            && let Some(view) = latest.get(session_id).cloned()
1432                        {
1433                            spawn_remote_actor(
1434                                session_id.clone(),
1435                                view,
1436                                &requests_tx,
1437                                &mut actors,
1438                                &updates_tx,
1439                            );
1440                        }
1441                    }
1442                }
1443                command = commands_rx.recv() => {
1444                    let Some(ManagerCommand::Session { session_id, reply }) = command else {
1445                        break;
1446                    };
1447                    let handle = actors.get(&session_id).map(|actor| ManagedSessionHandle {
1448                        session_id: session_id.clone(),
1449                        commands: actor.commands.clone(),
1450                        releases: actor.releases.clone(),
1451                        view: actor.view.clone(),
1452                    });
1453                    let _ = reply.send(handle);
1454                }
1455                published = published_rx.recv() => {
1456                    let Some(RemoteManagerUpdate::Publish { session_id, view }) = published else {
1457                        break;
1458                    };
1459                    latest.insert(session_id.clone(), view.clone());
1460                    if !desired.contains_key(&session_id) {
1461                        continue;
1462                    }
1463                    if let Some(actor) = actors.get(&session_id) {
1464                        publish_view(&session_id, view, &actor.view_tx, &updates_tx);
1465                        continue;
1466                    }
1467                    spawn_remote_actor(
1468                        session_id,
1469                        view,
1470                        &requests_tx,
1471                        &mut actors,
1472                        &updates_tx,
1473                    );
1474                }
1475            }
1476        }
1477        for actor in actors.into_values() {
1478            actor.abort.abort();
1479        }
1480    });
1481    Ok(RemoteSessionManagerChannels {
1482        targets: targets_tx,
1483        control: SessionManagerControl {
1484            commands: commands_tx,
1485        },
1486        updates: updates_rx,
1487        shutdown: SessionManagerShutdown {
1488            signal: Some(shutdown_tx),
1489            task: Some(task),
1490        },
1491        publisher: RemoteSessionPublisher {
1492            updates: published_tx,
1493        },
1494        requests: RemoteSessionRequests {
1495            requests: requests_rx,
1496        },
1497    })
1498}
1499
1500pub fn spawn_session_manager() -> Result<SessionManagerChannels> {
1501    let (targets_tx, mut targets_rx) = watch::channel(Vec::<RelaySessionTarget>::new());
1502    let (commands_tx, mut commands_rx) = mpsc::channel(32);
1503    let (updates_tx, updates_rx) = coalesced_update_channel();
1504    let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
1505    let task = tokio::spawn(async move {
1506        let mut actors = BTreeMap::<String, ActorRegistration>::new();
1507        let mut tasks = tokio::task::JoinSet::<String>::new();
1508        let mut desired_targets = BTreeMap::<String, RelaySessionTarget>::new();
1509        loop {
1510            tokio::select! {
1511                _ = &mut shutdown_rx => break,
1512                changed = targets_rx.changed() => {
1513                    if changed.is_err() {
1514                        break;
1515                    }
1516                    desired_targets = target_map(&targets_rx.borrow_and_update());
1517                    reconcile_actors(
1518                        &desired_targets,
1519                        &mut actors,
1520                        &mut tasks,
1521                        &updates_tx,
1522                    );
1523                }
1524                command = commands_rx.recv() => {
1525                    let Some(ManagerCommand::Session { session_id, reply }) = command else {
1526                        break;
1527                    };
1528                    let handle = actors
1529                        .get(&session_id)
1530                        .filter(|actor| !actor.commands.is_closed())
1531                        .filter(|actor| desired_targets.get(&session_id) == Some(&actor.target))
1532                        .map(|actor| ManagedSessionHandle {
1533                            session_id: session_id.clone(),
1534                            commands: actor.commands.clone(),
1535                            releases: actor.releases.clone(),
1536                            view: actor.view.clone(),
1537                        });
1538                    if reply.send(handle).is_err() {
1539                        tracing::debug!(
1540                            session_id = %session_id,
1541                            operation = "session_lookup",
1542                            "session lookup receiver was already closed"
1543                        );
1544                    }
1545                }
1546                joined = tasks.join_next_with_id(), if !tasks.is_empty() => {
1547                    match joined {
1548                        Some(Ok((task_id, session_id))) => {
1549                            let removed = remove_actor_task(&mut actors, task_id);
1550                            if removed.as_deref().is_some_and(|removed| removed != session_id) {
1551                                tracing::error!(
1552                                    completed_session_id = session_id,
1553                                    registered_session_id = removed,
1554                                    "session relay actor completed under the wrong registration"
1555                                );
1556                            }
1557                            // A watch sender may have published another target while this
1558                            // completion was already ready. Reconcile against its newest
1559                            // value so an intermediate replacement is never started.
1560                            desired_targets = target_map(&targets_rx.borrow());
1561                            reconcile_actors(
1562                                &desired_targets,
1563                                &mut actors,
1564                                &mut tasks,
1565                                &updates_tx,
1566                            );
1567                        }
1568                        Some(Err(error)) if error.is_cancelled() => {
1569                            let cancelled_task = error.id();
1570                            let session_id = remove_actor_task(&mut actors, cancelled_task);
1571                            desired_targets = target_map(&targets_rx.borrow());
1572                            reconcile_actors(
1573                                &desired_targets,
1574                                &mut actors,
1575                                &mut tasks,
1576                                &updates_tx,
1577                            );
1578                            tracing::warn!(
1579                                session_id = ?session_id,
1580                                "cancelled session relay actor was replaced"
1581                            );
1582                        }
1583                        Some(Err(error)) => {
1584                            let failed_task = error.id();
1585                            remove_actor_task(&mut actors, failed_task);
1586                            desired_targets = target_map(&targets_rx.borrow());
1587                            reconcile_actors(
1588                                &desired_targets,
1589                                &mut actors,
1590                                &mut tasks,
1591                                &updates_tx,
1592                            );
1593                            tracing::error!(%error, "session relay actor failed");
1594                        }
1595                        None => {}
1596                    }
1597                }
1598            }
1599        }
1600        shutdown_session_actors(&mut actors, &mut tasks).await;
1601    });
1602    Ok(SessionManagerChannels {
1603        targets: targets_tx,
1604        control: SessionManagerControl {
1605            commands: commands_tx,
1606        },
1607        updates: updates_rx,
1608        shutdown: SessionManagerShutdown {
1609            signal: Some(shutdown_tx),
1610            task: Some(task),
1611        },
1612    })
1613}
1614
1615async fn shutdown_session_actors(
1616    actors: &mut BTreeMap<String, ActorRegistration>,
1617    tasks: &mut tokio::task::JoinSet<String>,
1618) {
1619    for actor in actors.values() {
1620        actor.retirement.send_replace(true);
1621    }
1622    actors.clear();
1623
1624    let graceful = async {
1625        while let Some(joined) = tasks.join_next().await {
1626            match joined {
1627                Ok(_) => {}
1628                Err(error) if error.is_cancelled() => {}
1629                Err(error) => {
1630                    tracing::error!(%error, "session relay actor failed during shutdown");
1631                }
1632            }
1633        }
1634    };
1635    if tokio::time::timeout(SESSION_MANAGER_SHUTDOWN_GRACE, graceful)
1636        .await
1637        .is_ok()
1638    {
1639        return;
1640    }
1641
1642    tracing::warn!(
1643        timeout_ms = SESSION_MANAGER_SHUTDOWN_GRACE.as_millis(),
1644        "session relay actors did not stop before the shutdown deadline; aborting them"
1645    );
1646    tasks.abort_all();
1647    while let Some(joined) = tasks.join_next().await {
1648        if let Err(error) = joined
1649            && !error.is_cancelled()
1650        {
1651            tracing::error!(%error, "session relay actor failed while being aborted");
1652        }
1653    }
1654}
1655
1656async fn run_session_actor(
1657    target: RelaySessionTarget,
1658    mut commands: mpsc::Receiver<ActorCommand>,
1659    mut releases: mpsc::UnboundedReceiver<ReturnedConnection>,
1660    mut retirement: watch::Receiver<bool>,
1661    view_tx: watch::Sender<ManagedSessionView>,
1662    updates: CoalescedUpdateSender,
1663) {
1664    let mut connection: Option<StandaloneSession> = None;
1665    let mut failures = 0_u32;
1666    let mut last_recovery_probe = None;
1667    let mut lifecycle = ActorLifecycle::default();
1668    let mut deferred_submits: VecDeque<DeferredSubmit> = VecDeque::new();
1669    let mut next_lease_id = 1_u64;
1670    let mut reviewer_tasks = tokio::task::JoinSet::new();
1671    let mut reviewer_connections = BTreeMap::new();
1672    let mut reviewer_tails = BTreeMap::new();
1673    let mut reviewer_cancellation = tokio_util::sync::CancellationToken::new();
1674    let mut interval = tokio::time::interval(SESSION_SYNC_INTERVAL);
1675    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1676    loop {
1677        lifecycle.set_retirement_requested(*retirement.borrow_and_update());
1678        if lifecycle.should_stop() {
1679            break;
1680        }
1681        tokio::select! {
1682            completed = reviewer_tasks.join_next(), if !reviewer_tasks.is_empty() => {
1683                if let Some(Err(error)) = completed {
1684                    tracing::error!(session_id = %target.session_id, %error, "reviewer operation task failed");
1685                }
1686            }
1687            _ = interval.tick() => {
1688                lifecycle.set_retirement_requested(*retirement.borrow());
1689                if lifecycle.should_stop() {
1690                    break;
1691                }
1692                if lifecycle.is_leased() {
1693                    continue;
1694                }
1695                let result = sync_actor_connection(
1696                    &target,
1697                    &mut connection,
1698                ).await;
1699                match result {
1700                    Ok(snapshot) => {
1701                        failures = 0;
1702                        if let Some(snapshot) = snapshot {
1703                            publish_view(&target.session_id, ManagedSessionView {
1704                                snapshot: Some(snapshot),
1705                                connected: true,
1706                                error: None,
1707                            }, &view_tx, &updates);
1708                        }
1709                    }
1710                    Err(error) => {
1711                        connection = None;
1712                        failures = failures.saturating_add(1);
1713                        // A projection integrity failure repeats on every
1714                        // retry, so report it at once rather than waiting for
1715                        // the unreachable threshold.
1716                        let integrity = projection_integrity_failure(&error);
1717                        tracing::warn!(
1718                            session_id = target.session_id,
1719                            consecutive_failures = failures,
1720                            projection_integrity = integrity,
1721                            transport_dead = worker_connect_needs_restart(&error),
1722                            "session relay sync failed: {error:#}"
1723                        );
1724                        let recovery_due = !integrity
1725                            && failures >= UNREACHABLE_FAILURE_THRESHOLD
1726                            && worker_connect_needs_restart(&error)
1727                            && target.worker_recovery.is_some()
1728                            && last_recovery_probe.is_none_or(|last: tokio::time::Instant| {
1729                                last.elapsed() >= WORKER_RESTART_COOLDOWN
1730                            });
1731                        if integrity || failures >= UNREACHABLE_FAILURE_THRESHOLD {
1732                            // Bind the clone first: borrowing inside the call
1733                            // would hold the watch read guard while
1734                            // `publish_view` takes the write lock, deadlocking
1735                            // this actor on its own view.
1736                            let snapshot = view_tx.borrow().snapshot.clone();
1737                            let mut detail = format!("{error:#}");
1738                            if recovery_due {
1739                                detail.push_str("; checking whether the relay worker is dead");
1740                            }
1741                            publish_view(&target.session_id, ManagedSessionView {
1742                                snapshot,
1743                                connected: false,
1744                                error: Some(if integrity {
1745                                    ViewError::ProjectionIntegrity(detail)
1746                                } else {
1747                                    ViewError::Unreachable(detail)
1748                                }),
1749                            }, &view_tx, &updates);
1750                        }
1751                        if recovery_due {
1752                            last_recovery_probe = Some(tokio::time::Instant::now());
1753                            let plan = target
1754                                .worker_recovery
1755                                .clone()
1756                                .expect("recovery eligibility requires a plan");
1757                            let restart_unresponsive =
1758                                worker_connect_allows_live_restart(&error);
1759                            tracing::warn!(
1760                                session_id = target.session_id,
1761                                "relay worker is unreachable; probing it before recovery: {error:#}"
1762                            );
1763                            match recover_worker(plan, restart_unresponsive).await {
1764                                Ok(
1765                                    outcome @ (WorkerRecoveryOutcome::RestartedDead
1766                                    | WorkerRecoveryOutcome::RestartedUnresponsive),
1767                                ) => {
1768                                    failures = 0;
1769                                    let snapshot = view_tx.borrow().snapshot.clone();
1770                                    let recovery = match outcome {
1771                                        WorkerRecoveryOutcome::RestartedDead => {
1772                                            "confirmed the relay worker was dead and restarted it"
1773                                        }
1774                                        WorkerRecoveryOutcome::RestartedUnresponsive => {
1775                                            "the relay worker was alive but not serving handshakes, so it was restarted"
1776                                        }
1777                                        WorkerRecoveryOutcome::Alive
1778                                        | WorkerRecoveryOutcome::Starting
1779                                        | WorkerRecoveryOutcome::TargetMissing
1780                                        | WorkerRecoveryOutcome::WorkspaceMissing(_) => {
1781                                            unreachable!()
1782                                        }
1783                                    };
1784                                    publish_view(&target.session_id, ManagedSessionView {
1785                                        snapshot,
1786                                        connected: false,
1787                                        error: Some(ViewError::Unreachable(format!(
1788                                            "{error:#}; {recovery}"
1789                                        ))),
1790                                    }, &view_tx, &updates);
1791                                    interval.reset_after(RECONNECT_INTERVAL);
1792                                }
1793                                Ok(WorkerRecoveryOutcome::Alive) => {
1794                                    tracing::warn!(
1795                                        session_id = target.session_id,
1796                                        "relay transport failed but the worker is alive; leaving it running"
1797                                    );
1798                                    let snapshot = view_tx.borrow().snapshot.clone();
1799                                    publish_view(&target.session_id, ManagedSessionView {
1800                                        snapshot,
1801                                        connected: false,
1802                                        error: Some(ViewError::Unreachable(format!(
1803                                            "{error:#}; relay worker is still alive, so it was not restarted"
1804                                        ))),
1805                                    }, &view_tx, &updates);
1806                                    interval.reset_after(reconnect_delay(failures));
1807                                }
1808                                Ok(WorkerRecoveryOutcome::Starting) => {
1809                                    tracing::warn!(
1810                                        session_id = target.session_id,
1811                                        "relay worker is still starting; leaving it running"
1812                                    );
1813                                    let snapshot = view_tx.borrow().snapshot.clone();
1814                                    publish_view(&target.session_id, ManagedSessionView {
1815                                        snapshot,
1816                                        connected: false,
1817                                        error: Some(ViewError::Unreachable(format!(
1818                                            "{error:#}; relay worker is still recovering its durable state, so it was not restarted"
1819                                        ))),
1820                                    }, &view_tx, &updates);
1821                                    interval.reset_after(reconnect_delay(failures));
1822                                }
1823                                Ok(WorkerRecoveryOutcome::TargetMissing) => {
1824                                    let snapshot = view_tx.borrow().snapshot.clone();
1825                                    publish_view(&target.session_id, ManagedSessionView {
1826                                        snapshot,
1827                                        connected: false,
1828                                        error: Some(ViewError::TargetMissing(
1829                                            "the managed Podman session container no longer exists"
1830                                                .into(),
1831                                        )),
1832                                    }, &view_tx, &updates);
1833                                    interval.reset_after(RECONNECT_BACKOFF_CEILING);
1834                                }
1835                                Ok(WorkerRecoveryOutcome::WorkspaceMissing(directory)) => {
1836                                    let snapshot = view_tx.borrow().snapshot.clone();
1837                                    publish_view(&target.session_id, ManagedSessionView {
1838                                        snapshot,
1839                                        connected: false,
1840                                        error: Some(ViewError::TargetMissing(format!(
1841                                            "the worker working directory {} is missing; resume this session from its recovery archive to restore it",
1842                                            directory.display(),
1843                                        ))),
1844                                    }, &view_tx, &updates);
1845                                    interval.reset_after(RECONNECT_BACKOFF_CEILING);
1846                                }
1847                                Err(recovery_error) => {
1848                                    tracing::warn!(
1849                                        session_id = target.session_id,
1850                                        "automatic relay worker recovery failed safely: {recovery_error:#}"
1851                                    );
1852                                    let snapshot = view_tx.borrow().snapshot.clone();
1853                                    publish_view(&target.session_id, ManagedSessionView {
1854                                        snapshot,
1855                                        connected: false,
1856                                        error: Some(ViewError::Unreachable(format!(
1857                                            "{error:#}; could not confirm the relay worker was dead, so it was not restarted: {recovery_error:#}"
1858                                        ))),
1859                                    }, &view_tx, &updates);
1860                                    interval.reset_after(reconnect_delay(failures));
1861                                }
1862                            }
1863                        } else {
1864                            interval.reset_after(reconnect_delay(failures));
1865                        }
1866                    }
1867                }
1868            }
1869            command = commands.recv() => {
1870                let Some(command) = command else { break };
1871                lifecycle.set_retirement_requested(*retirement.borrow());
1872                if !lifecycle.accepts_new_work() {
1873                    tracing::debug!(
1874                        session_id = %target.session_id,
1875                        operation = command.operation_name(),
1876                        "rejecting relay operation while session target changes"
1877                    );
1878                    command.reject(&target.session_id, "session target is changing");
1879                    continue;
1880                }
1881                match command {
1882                    ActorCommand::Submit {
1883                        command_id,
1884                        command,
1885                        admission,
1886                        reply,
1887                    } => {
1888                        if crate::hel_controller::move_session::move_refuses_command(&target.session_id, &command) {
1889                            let _ = reply.send(Err("session is moving; keep the draft and retry after Move finishes".into()));
1890                            continue;
1891                        }
1892                        // A turn under review holds its session's prompts. The
1893                        // sole exception is a capability issued by the review
1894                        // host for this exact corrective command; ordinary
1895                        // prompts and controller-authored notices still take
1896                        // the refusal path below.
1897                        let admitted = admission.as_ref().is_some_and(|admission| {
1898                            matches!(&command, RelayCommand::Prompt { .. })
1899                                && admission.command_id() == command_id
1900                                && crate::hel_review_host::review_delivery_admitted(
1901                                    &target.session_id,
1902                                    admission,
1903                                )
1904                        });
1905                        if admission.is_some() && !admitted {
1906                            let _ = reply.send(Err(
1907                                "review delivery admission is no longer valid".to_owned(),
1908                            ));
1909                            continue;
1910                        }
1911                        if matches!(&command, RelayCommand::Prompt { .. })
1912                            && !admitted
1913                            && let Some(refusal) =
1914                                crate::hel_review_host::prompt_refusal(&target.session_id)
1915                        {
1916                            tracing::debug!(
1917                                session_id = %target.session_id,
1918                                %command_id,
1919                                "refusing a prompt while a turn review is unresolved"
1920                            );
1921                            let _ = reply.send(Err(refusal.to_owned()));
1922                            continue;
1923                        }
1924                        if lifecycle.is_leased() {
1925                            // A checkpoint or other lifecycle operation owns the
1926                            // connection. Hold the prompt instead of rejecting it
1927                            // and deliver it when the lease comes back.
1928                            deferred_submits.push_back(DeferredSubmit {
1929                                command_id,
1930                                command,
1931                                admission,
1932                                reply,
1933                            });
1934                            continue;
1935                        }
1936                        deliver_submit(
1937                            &target,
1938                            &mut connection,
1939                            DeferredSubmit { command_id, command, admission, reply },
1940                            &view_tx,
1941                            &updates,
1942                        )
1943                        .await;
1944                    }
1945                    ActorCommand::Sync { reply } => {
1946                        if lifecycle.is_leased() {
1947                            tracing::debug!(
1948                                session_id = %target.session_id,
1949                                operation = "sync",
1950                                "rejecting sync while session is leased"
1951                            );
1952                            if reply
1953                                .send(Err("session is reserved for a lifecycle operation".into()))
1954                                .is_err()
1955                            {
1956                                tracing::debug!(
1957                                    session_id = %target.session_id,
1958                                    operation = "sync",
1959                                    "sync rejection receiver was already closed"
1960                                );
1961                            }
1962                            continue;
1963                        }
1964                        let result = sync_actor_connection(
1965                            &target,
1966                            &mut connection,
1967                        ).await.map(|snapshot| {
1968                            if let Some(snapshot) = snapshot {
1969                                publish_view(&target.session_id, ManagedSessionView {
1970                                    snapshot: Some(snapshot),
1971                                    connected: true,
1972                                    error: None,
1973                                }, &view_tx, &updates);
1974                            }
1975                        });
1976                        if result.is_err() {
1977                            connection = None;
1978                        }
1979                        if let Err(error) = &result {
1980                            tracing::warn!(
1981                                session_id = %target.session_id,
1982                                operation = "sync",
1983                                error = %error,
1984                                "explicit relay synchronization failed"
1985                            );
1986                        }
1987                    if reply.send(result.map_err(|error| format!("{error:#}"))).is_err() {
1988                        tracing::debug!(
1989                            session_id = %target.session_id,
1990                            operation = "sync",
1991                            "sync result receiver was already closed"
1992                        );
1993                    }
1994                    }
1995                    ActorCommand::Reviewer {
1996                        role,
1997                        action,
1998                        reply,
1999                    } => {
2000                        if lifecycle.is_leased() || crate::hel_controller::move_session::move_owns_session(&target.session_id) {
2001                            // A lifecycle operation owns the connection, and a
2002                            // reviewer action is not worth deferring: the user
2003                            // is waiting on its answer now.
2004                            tracing::debug!(
2005                                session_id = %target.session_id,
2006                                operation = action.operation_name(),
2007                                "rejecting a reviewer action while the session is leased"
2008                            );
2009                            if reply
2010                                .send(Err("session is reserved for a lifecycle operation".into()))
2011                                .is_err()
2012                            {
2013                                tracing::debug!(
2014                                    session_id = %target.session_id,
2015                                    operation = "reviewer",
2016                                    "reviewer rejection receiver was already closed"
2017                                );
2018                            }
2019                            continue;
2020                        }
2021                        // A slow harness startup or analysis must not occupy
2022                        // the primary's relay or serialize independent roles.
2023                        // Cache each role's connection so transcript polling
2024                        // does not launch a new SSH/Podman proxy every time.
2025                        let cached = reviewer_connections.entry(role.clone())
2026                            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None)))
2027                            .clone();
2028                        let (finished, tail) = oneshot::channel::<()>();
2029                        let previous = reviewer_tails.insert(role.clone(), tail);
2030                        let target = target.clone();
2031                        let cancelled = reviewer_cancellation.clone();
2032                        reviewer_tasks.spawn(async move {
2033                            if let Some(previous) = previous {
2034                                let _ = previous.await;
2035                            }
2036                            run_reviewer_operation(target, role, action, reply, cached, cancelled).await;
2037                            drop(finished);
2038                        });
2039                    }
2040                    ActorCommand::RespondElicitation {
2041                        elicitation_id,
2042                        response,
2043                        reply,
2044                    } => {
2045                        if lifecycle.is_leased() {
2046                            tracing::debug!(
2047                                session_id = %target.session_id,
2048                                operation = "respond_elicitation",
2049                                "rejecting elicitation response while session is leased"
2050                            );
2051                            if reply
2052                                .send(Err("session is reserved for a lifecycle operation".into()))
2053                                .is_err()
2054                            {
2055                                tracing::debug!(
2056                                    session_id = %target.session_id,
2057                                    operation = "respond_elicitation",
2058                                    "elicitation rejection receiver was already closed"
2059                                );
2060                            }
2061                            continue;
2062                        }
2063                        let result = async {
2064                            sync_actor_connection(&target, &mut connection).await?;
2065                            let connection = connection
2066                                .as_mut()
2067                                .context("relay is disconnected")?;
2068                            connection
2069                                .respond_elicitation(elicitation_id, response)
2070                                .await?;
2071                            Ok::<_, anyhow::Error>(connection.snapshot())
2072                        }
2073                        .await;
2074                        match result {
2075                            Ok(ref snapshot) => publish_view(
2076                                &target.session_id,
2077                                ManagedSessionView {
2078                                    snapshot: Some(snapshot.clone()),
2079                                    connected: true,
2080                                    error: None,
2081                                },
2082                                &view_tx,
2083                                &updates,
2084                            ),
2085                            Err(ref error) if !is_final_rejection(error) => connection = None,
2086                            Err(_) => {}
2087                        }
2088                        if let Err(error) = &result {
2089                            tracing::warn!(
2090                                session_id = %target.session_id,
2091                                operation = "respond_elicitation",
2092                                error = %error,
2093                                "relay elicitation response failed"
2094                            );
2095                        }
2096                        if reply
2097                            .send(result.map(|_| ()).map_err(|error| format!("{error:#}")))
2098                            .is_err()
2099                        {
2100                            tracing::debug!(
2101                                session_id = %target.session_id,
2102                                operation = "respond_elicitation",
2103                                "elicitation result receiver was already closed"
2104                            );
2105                        }
2106                    }
2107                    ActorCommand::Lease { reply } => {
2108                        if lifecycle.is_leased() {
2109                            tracing::debug!(
2110                                session_id = %target.session_id,
2111                                operation = "lease",
2112                                "rejecting duplicate session lifecycle lease"
2113                            );
2114                            if reply
2115                                .send(Err(anyhow::anyhow!(
2116                                    "session already has a lifecycle operation"
2117                                )))
2118                                .is_err()
2119                            {
2120                                tracing::debug!(
2121                                    session_id = %target.session_id,
2122                                    operation = "lease",
2123                                    "lease rejection receiver was already closed"
2124                                );
2125                            }
2126                            continue;
2127                        }
2128                        let lease_id = next_lease_id;
2129                        reviewer_cancellation.cancel();
2130                        reviewer_cancellation = tokio_util::sync::CancellationToken::new();
2131                        reviewer_connections.clear();
2132                        reviewer_tails.clear();
2133                        let result = sync_actor_connection(
2134                            &target,
2135                            &mut connection,
2136                        )
2137                        .await
2138                        .map(|_| {
2139                            next_lease_id = next_lease_id.wrapping_add(1).max(1);
2140                            (
2141                                lease_id,
2142                                connection
2143                                    .take()
2144                                    .expect("successful sync retained its connection"),
2145                            )
2146                        });
2147                        if result.is_err() {
2148                            connection = None;
2149                        }
2150                        if let Err(error) = &result {
2151                            tracing::warn!(
2152                                session_id = %target.session_id,
2153                                operation = "lease",
2154                                error = %error,
2155                                "could not acquire relay session lease"
2156                            );
2157                        }
2158                        let acquired = result.is_ok();
2159                        match reply.send(result) {
2160                            Ok(()) if acquired => lifecycle.activate_lease(lease_id),
2161                            Ok(()) => {}
2162                            Err(Ok((_lease_id, returned))) => connection = Some(returned),
2163                            Err(Err(_)) => {}
2164                        }
2165                    }
2166                }
2167            }
2168            returned = releases.recv() => {
2169                let Some(returned) = returned else { continue };
2170                if lifecycle.return_lease(returned.lease_id) {
2171                    // A dropped lease returns no connection; `submit_actor_command`
2172                    // reconnects on demand, so the drain needs no special case.
2173                    connection = returned.connection;
2174                    failures = 0;
2175                    interval.reset();
2176                    // A lease syncs the connection it borrowed, so this actor's
2177                    // next sync can find nothing left to apply. Publish what the
2178                    // returned connection already knows or watchers keep reading
2179                    // pre-lease state.
2180                    if let Some(returned) = connection.as_ref() {
2181                        publish_view(&target.session_id, ManagedSessionView {
2182                            snapshot: Some(returned.snapshot()),
2183                            connected: true,
2184                            error: None,
2185                        }, &view_tx, &updates);
2186                    }
2187                    let retiring = *retirement.borrow();
2188                    while let Some(deferred) = deferred_submits.pop_front() {
2189                        if retiring {
2190                            if deferred
2191                                .reply
2192                                .send(Err("session target is changing".into()))
2193                                .is_err()
2194                            {
2195                                tracing::debug!(
2196                                    session_id = %target.session_id,
2197                                    operation = "submit",
2198                                    "deferred submit rejection receiver was already closed"
2199                                );
2200                            }
2201                            continue;
2202                        }
2203                        deliver_submit(
2204                            &target,
2205                            &mut connection,
2206                            deferred,
2207                            &view_tx,
2208                            &updates,
2209                        )
2210                        .await;
2211                    }
2212                }
2213            }
2214            changed = retirement.changed() => {
2215                if changed.is_err() {
2216                    break;
2217                }
2218            }
2219        }
2220    }
2221    reviewer_cancellation.cancel();
2222    reviewer_connections.clear();
2223    reviewer_tails.clear();
2224    while let Some(completed) = reviewer_tasks.join_next().await {
2225        if let Err(error) = completed {
2226            tracing::error!(session_id = %target.session_id, %error, "reviewer operation task failed during shutdown");
2227        }
2228    }
2229    if let Some(connection) = connection.take()
2230        && let Err(error) = connection.detach().await
2231    {
2232        tracing::warn!(
2233            session_id = %target.session_id,
2234            %error,
2235            "could not detach relay connection during session actor shutdown"
2236        );
2237    }
2238    // No caller may wait forever on a submission this actor will never deliver.
2239    for deferred in deferred_submits {
2240        if deferred
2241            .reply
2242            .send(Err("session manager stopped".into()))
2243            .is_err()
2244        {
2245            tracing::debug!(
2246                session_id = %target.session_id,
2247                operation = "submit",
2248                "deferred submit shutdown receiver was already closed"
2249            );
2250        }
2251    }
2252}
2253
2254/// Submit one relay command and publish the resulting snapshot. Live and
2255/// deferred submissions share this path so both report identical results.
2256async fn deliver_submit(
2257    target: &RelaySessionTarget,
2258    connection: &mut Option<StandaloneSession>,
2259    submission: DeferredSubmit,
2260    view_tx: &watch::Sender<ManagedSessionView>,
2261    updates: &CoalescedUpdateSender,
2262) {
2263    let DeferredSubmit {
2264        command_id,
2265        command,
2266        admission,
2267        reply,
2268    } = submission;
2269    if crate::hel_controller::move_session::move_refuses_command(&target.session_id, &command) {
2270        let _ = reply.send(Err(
2271            "session is moving; keep the draft and retry after Move finishes".into(),
2272        ));
2273        return;
2274    }
2275    if let Some(admission) = admission.as_ref()
2276        && (!matches!(&command, RelayCommand::Prompt { .. })
2277            || admission.command_id() != command_id
2278            || !crate::hel_review_host::review_delivery_admitted(&target.session_id, admission))
2279    {
2280        let _ = reply.send(Err(
2281            "review delivery admission is no longer valid".to_owned()
2282        ));
2283        return;
2284    }
2285    let result = submit_actor_command(target, connection, &command_id, &command).await;
2286    if let Err(error) = result.as_ref() {
2287        tracing::warn!(
2288            session_id = %target.session_id,
2289            operation = "submit",
2290            %command_id,
2291            retryable = !is_final_rejection(error),
2292            error = %error,
2293            "relay command submission failed"
2294        );
2295    }
2296    if let Err(error) = result.as_ref()
2297        && !is_final_rejection(error)
2298    {
2299        *connection = None;
2300    }
2301    let accepted = result.as_ref().ok().copied();
2302    // Answer the caller the moment the relay has the command. Catching the
2303    // local projection up to it is the expensive half and nobody waiting to
2304    // hear "accepted" needs it first: the caller has an ordinal, and the view
2305    // it would read is published below anyway.
2306    if reply
2307        .send(result.map_err(|error| format!("{error:#}")))
2308        .is_err()
2309    {
2310        tracing::debug!(
2311            session_id = %target.session_id,
2312            operation = "submit",
2313            %command_id,
2314            "submit result receiver was already closed"
2315        );
2316    }
2317    let Some(ordinal) = accepted else {
2318        return;
2319    };
2320    tracing::trace!(%ordinal, %command_id, "relay command accepted");
2321    let Some(session) = connection.as_mut() else {
2322        return;
2323    };
2324    // The command landed either way, so a failed catch-up is a connection
2325    // problem to retire rather than a failed submission: the caller has
2326    // already been told the relay took it.
2327    match session.sync().await {
2328        Ok(snapshot) => publish_view(
2329            &target.session_id,
2330            ManagedSessionView {
2331                snapshot: Some(snapshot),
2332                connected: true,
2333                error: None,
2334            },
2335            view_tx,
2336            updates,
2337        ),
2338        Err(error) => {
2339            tracing::warn!(
2340                session_id = %target.session_id,
2341                operation = "submit",
2342                %command_id,
2343                error = %format!("{error:#}"),
2344                "projection could not catch up to an accepted command"
2345            );
2346            *connection = None;
2347        }
2348    }
2349}
2350
2351/// Whether the relay refused this request outright.
2352///
2353/// A refusal is a completed round trip, so the connection is healthy. Dropping
2354/// it would discard whatever that connection owns on the worker, including a
2355/// checkpoint barrier a controller is still holding.
2356fn is_final_rejection(error: &anyhow::Error) -> bool {
2357    error
2358        .downcast_ref::<RelayRejected>()
2359        .is_some_and(|rejected| !rejected.is_retryable())
2360}
2361
2362async fn submit_actor_command(
2363    target: &RelaySessionTarget,
2364    connection: &mut Option<StandaloneSession>,
2365    command_id: &str,
2366    command: &RelayCommand,
2367) -> Result<u64> {
2368    let mut first_error = None;
2369    for attempt in 1..=2 {
2370        if connection.is_none() {
2371            sync_actor_connection(target, connection).await?;
2372        }
2373        let result = connection
2374            .as_mut()
2375            .context("relay is disconnected")?
2376            .submit_accepted(command_id.to_owned(), command.clone())
2377            .await;
2378        match result {
2379            Ok(ordinal) => return Ok(ordinal),
2380            // A final rejection is a completed round trip: the relay read the
2381            // command and refused it, so retrying would only be refused again.
2382            // Reconnecting would also cancel any checkpoint barrier this
2383            // connection owns, which is how a controller probing for a command
2384            // an older worker does not understand would lose it.
2385            Err(error) if is_final_rejection(&error) => return Err(error),
2386            Err(error) => {
2387                tracing::warn!(
2388                    session_id = %target.session_id,
2389                    operation = "submit",
2390                    %command_id,
2391                    attempt,
2392                    retryable = true,
2393                    error = %error,
2394                    "retryable relay command failure; reconnecting"
2395                );
2396                if first_error.is_none() {
2397                    first_error = Some(format!("{error:#}"));
2398                }
2399                *connection = None;
2400            }
2401        }
2402    }
2403    let detail = first_error.unwrap_or_else(|| "relay submission failed".into());
2404    bail!("relay command {command_id} failed after an idempotent reconnect: {detail}")
2405}
2406
2407/// Perform one reviewer action on a synchronized relay connection.
2408///
2409/// The reviewer's own relay answers most of these, so the outcomes mirror the
2410/// primary's: an attach page, an acknowledgement cursor, an accepted command.
2411async fn run_reviewer_operation(
2412    target: RelaySessionTarget,
2413    role: Option<String>,
2414    action: ReviewerAction,
2415    mut reply: oneshot::Sender<std::result::Result<ReviewerOutcome, String>>,
2416    cached: Arc<tokio::sync::Mutex<Option<RelayClient>>>,
2417    cancelled: tokio_util::sync::CancellationToken,
2418) {
2419    let operation = action.operation_name();
2420    let keep_connection = !matches!(&action, ReviewerAction::Pause);
2421    let result = tokio::select! {
2422        biased;
2423        _ = cancelled.cancelled() => Err(anyhow::anyhow!("reviewer operation cancelled for session lifecycle change")),
2424        _ = reply.closed() => return,
2425        result = async {
2426            let mut cache = cached.lock().await;
2427            // Take ownership while a request is in flight: dropping this
2428            // future closes its connection instead of leaving a late reply
2429            // available for the next request to misinterpret.
2430            let mut client = match cache.take() {
2431                Some(client) => client,
2432                None => RelayClient::connect(&target.spec, &target.session_id).await?,
2433            };
2434            let result = drive_reviewer(&mut client, role, action).await;
2435            if keep_connection && (result.is_ok() || result.as_ref().is_err_and(is_final_rejection)) {
2436                *cache = Some(client);
2437            }
2438            result
2439        } => result,
2440    };
2441    if let Err(error) = &result {
2442        tracing::warn!(session_id = %target.session_id, %operation, error = %error, "reviewer action failed");
2443    }
2444    if reply
2445        .send(result.map_err(|error| format!("{error:#}")))
2446        .is_err()
2447    {
2448        tracing::debug!(session_id = %target.session_id, %operation, "reviewer result receiver was already closed");
2449    }
2450}
2451
2452async fn drive_reviewer(
2453    client: &mut RelayClient,
2454    role: Option<String>,
2455    action: ReviewerAction,
2456) -> Result<ReviewerOutcome> {
2457    let role = role.as_deref();
2458    Ok(match action {
2459        ReviewerAction::Start { config } => {
2460            ReviewerOutcome::Started(Box::new(client.start_reviewer(role, *config).await?))
2461        }
2462        ReviewerAction::Submit {
2463            command_id,
2464            command,
2465        } => ReviewerOutcome::Accepted {
2466            ordinal: client.submit_to_reviewer(role, command_id, command).await?,
2467        },
2468        ReviewerAction::Attach {
2469            after_ordinal,
2470            after_digest,
2471        } => ReviewerOutcome::Attached(Box::new(
2472            client
2473                .attach_reviewer(role, after_ordinal, after_digest)
2474                .await?,
2475        )),
2476        ReviewerAction::Acknowledge {
2477            through_ordinal,
2478            through_digest,
2479        } => ReviewerOutcome::Acknowledged(
2480            client
2481                .acknowledge_reviewer(role, through_ordinal, through_digest)
2482                .await?,
2483        ),
2484        ReviewerAction::Status => {
2485            ReviewerOutcome::Status(Box::new(client.reviewer_status(role).await?))
2486        }
2487        ReviewerAction::RespondElicitation {
2488            elicitation_id,
2489            response,
2490        } => {
2491            client
2492                .respond_to_reviewer(role, elicitation_id, response)
2493                .await?;
2494            ReviewerOutcome::ElicitationResolved
2495        }
2496        ReviewerAction::Pause => {
2497            client.pause_reviewer(role).await?;
2498            ReviewerOutcome::Paused
2499        }
2500        ReviewerAction::CaptureDelta { baselines } => ReviewerOutcome::Delta {
2501            repositories: client.capture_review_delta(role, baselines).await?,
2502        },
2503        ReviewerAction::AdvanceBaseline { trees } => {
2504            client.advance_review_baseline(role, trees).await?;
2505            ReviewerOutcome::BaselineAdvanced
2506        }
2507        ReviewerAction::AnalyzeDelta { repositories } => ReviewerOutcome::ChangedFunctions {
2508            packet: client.analyze_review_delta(role, repositories).await?,
2509        },
2510        ReviewerAction::TakeLaneDispatches => ReviewerOutcome::LaneDispatches {
2511            requests: client.take_lane_dispatches().await?,
2512        },
2513    })
2514}
2515
2516async fn sync_actor_connection(
2517    target: &RelaySessionTarget,
2518    connection: &mut Option<StandaloneSession>,
2519) -> Result<Option<ManagedSessionSnapshot>> {
2520    if connection.is_none() {
2521        *connection = Some(StandaloneSession::connect(target).await?);
2522        return Ok(Some(
2523            connection
2524                .as_ref()
2525                .expect("connection was initialized")
2526                .snapshot(),
2527        ));
2528    }
2529    let connection = connection.as_mut().expect("connection was initialized");
2530    if connection.sync_in_place().await? {
2531        Ok(Some(connection.snapshot()))
2532    } else {
2533        Ok(None)
2534    }
2535}
2536
2537/// Cheap equivalence for published views.
2538///
2539/// The materialized projection is a function of the relay event chain, so its
2540/// transcript can only differ when the applied event frontier differs. Every
2541/// sync tick would otherwise walk the whole conversation to prove nothing
2542/// changed. The remaining scalars are compared directly because they are small
2543/// and bound the projection's non-transcript state.
2544fn view_is_unchanged(current: &ManagedSessionView, next: &ManagedSessionView) -> bool {
2545    if current.connected != next.connected || current.error != next.error {
2546        return false;
2547    }
2548    match (&current.snapshot, &next.snapshot) {
2549        (None, None) => true,
2550        (Some(current), Some(next)) => {
2551            let (current_session, next_session) = (&current.materialized, &next.materialized);
2552            current.latest_credential_sync_signal == next.latest_credential_sync_signal
2553                && current.operational == next.operational
2554                && current_session.session_id == next_session.session_id
2555                && current_session.applied_event_ordinal == next_session.applied_event_ordinal
2556                && current_session.applied_event_digest == next_session.applied_event_digest
2557                && current_session.last_activity_at_ms == next_session.last_activity_at_ms
2558                && current_session.execution == next_session.execution
2559                && current_session.session_title == next_session.session_title
2560                && current_session.queued_prompts == next_session.queued_prompts
2561        }
2562        (None, Some(_)) | (Some(_), None) => false,
2563    }
2564}
2565
2566fn publish_view(
2567    session_id: &str,
2568    view: ManagedSessionView,
2569    watch: &watch::Sender<ManagedSessionView>,
2570    updates: &CoalescedUpdateSender,
2571) {
2572    // Compare and replace under one lock acquisition; a separate
2573    // `watch.borrow()` check would reacquire the lock and invite the
2574    // read-then-write deadlock this function's callers must avoid.
2575    let changed = watch.send_if_modified(|current| {
2576        if view_is_unchanged(current, &view) {
2577            return false;
2578        }
2579        *current = view.clone();
2580        true
2581    });
2582    if changed {
2583        updates.send(SessionManagerUpdate {
2584            session_id: session_id.to_owned(),
2585            view,
2586        });
2587    }
2588}
2589
2590/// Read a stored projection without blocking the runtime. The rusqlite read
2591/// and the transcript deserialization behind it are synchronous and grow with
2592/// the conversation, so a long session must not stall a worker thread that
2593/// other actors share.
2594async fn load_projection(session_id: &str) -> Result<MaterializedSession> {
2595    let session_id = session_id.to_owned();
2596    tokio::task::spawn_blocking(move || -> Result<MaterializedSession> {
2597        let loaded = hel::hel_database::load_materialized_session(&session_id)?;
2598        Ok(loaded.unwrap_or_else(|| MaterializedSession::empty(session_id)))
2599    })
2600    .await
2601    .context("controller projection load task failed")?
2602}
2603
2604pub struct StandaloneSession {
2605    client: RelayClient,
2606    materialized: MaterializedSession,
2607    operational: RelayOperationalState,
2608    latest_credential_sync_signal: Option<CredentialSyncSignal>,
2609    project_memory: Option<ProjectMemorySyncTarget>,
2610}
2611
2612impl StandaloneSession {
2613    pub fn set_project_memory_target(&mut self, target: Option<ProjectMemorySyncTarget>) {
2614        self.project_memory = target;
2615    }
2616
2617    pub async fn connect(target: &RelaySessionTarget) -> Result<Self> {
2618        // Reach the worker before reading the projection. A stored session can
2619        // be tens of megabytes, and the reconnect loop would otherwise pay that
2620        // whole synchronous read on every attempt against a worker that is down.
2621        let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
2622        let operational = client.status().await?;
2623        let materialized = load_projection(&target.session_id).await?;
2624        let mut connection = Self {
2625            client,
2626            materialized,
2627            operational,
2628            latest_credential_sync_signal: None,
2629            project_memory: target.project_memory.clone(),
2630        };
2631        connection.sync_in_place().await?;
2632        Ok(connection)
2633    }
2634
2635    pub async fn connect_command(spec: &CommandSpec, session_id: &str) -> Result<Self> {
2636        Self::connect(&RelaySessionTarget {
2637            session_id: session_id.to_owned(),
2638            spec: spec.clone(),
2639            worker_recovery: None,
2640            project_memory: None,
2641        })
2642        .await
2643    }
2644
2645    /// Protocol negotiated with the worker behind this connection. Lifecycle
2646    /// operations use it to avoid sending a newly introduced command to an
2647    /// older worker that cannot decode it.
2648    pub fn protocol_version(&self) -> u32 {
2649        self.client.protocol_version()
2650    }
2651
2652    async fn detach(self) -> Result<()> {
2653        self.client.detach().await
2654    }
2655
2656    pub async fn sync(&mut self) -> Result<ManagedSessionSnapshot> {
2657        self.sync_in_place().await?;
2658        Ok(self.snapshot())
2659    }
2660
2661    async fn sync_in_place(&mut self) -> Result<bool> {
2662        let original_ordinal = self.materialized.applied_event_ordinal;
2663        let original_digest = self.materialized.applied_event_digest.clone();
2664        let original_operational = self.operational.clone();
2665        let mut repaired = false;
2666        let mut repaired_frontiers = std::collections::HashSet::new();
2667        loop {
2668            let after_ordinal = self.materialized.applied_event_ordinal;
2669            match self.catch_up_fixed_frontier().await {
2670                Ok(()) => break,
2671                Err(error) if error.downcast_ref::<ProjectionAdvancedError>().is_some() => {
2672                    let durable = load_projection(&self.materialized.session_id).await?;
2673                    if durable.applied_event_ordinal <= after_ordinal {
2674                        return Err(error);
2675                    }
2676                    self.materialized = durable;
2677                    continue;
2678                }
2679                Err(error) if relay_desynchronized(&error) => {
2680                    self.repair_projection()
2681                        .await
2682                        .with_context(|| {
2683                            format!(
2684                                "controller projection for {} cannot catch up from ordinal {after_ordinal}: {error:#}",
2685                                self.materialized.session_id
2686                            )
2687                        })?;
2688                    repaired = true;
2689                    // Repair rebuilds from the same durable checkpoint every
2690                    // time. If catching up from that frontier still desyncs — as
2691                    // it does when relay history is unreadable past the
2692                    // checkpoint — repairing again lands on the same frontier and
2693                    // would loop forever. Fail loudly on the second visit instead
2694                    // of hanging; recovery got everything the checkpoint covers.
2695                    let frontier = self.materialized.applied_event_ordinal;
2696                    if !repaired_frontiers.insert(frontier) {
2697                        bail!(
2698                            "controller projection for {} cannot catch up: relay history is \
2699                             unreadable and rebuilding from checkpoint frontier {frontier} does \
2700                             not get past it",
2701                            self.materialized.session_id
2702                        );
2703                    }
2704                    continue;
2705                }
2706                Err(error) => return Err(error),
2707            }
2708        }
2709        let changed = repaired
2710            || self.materialized.applied_event_ordinal != original_ordinal
2711            || self.materialized.applied_event_digest != original_digest
2712            || self.operational != original_operational;
2713        Ok(changed)
2714    }
2715
2716    /// Apply relay pages through the exact frontier captured by the first
2717    /// response, then acknowledge that frontier once. Every projection page is
2718    /// independently durable; delaying the relay's GC watermark avoids one
2719    /// snapshot fsync per transport-sized page without risking redelivery.
2720    async fn catch_up_fixed_frontier(&mut self) -> Result<()> {
2721        let after = RelayCursor {
2722            ordinal: self.materialized.applied_event_ordinal,
2723            digest: self.materialized.applied_event_digest.clone(),
2724        };
2725        let catch_up = self
2726            .client
2727            .begin_catch_up(after.ordinal, &after.digest)
2728            .await?;
2729        let mut cursor = self.apply_event_page(catch_up.first_page).await?;
2730        let mut pages_remaining = catch_up.frontier.ordinal.saturating_sub(cursor.ordinal);
2731        while cursor.ordinal < catch_up.frontier.ordinal {
2732            ensure!(
2733                pages_remaining > 0,
2734                "relay catch-up exceeded its fixed page bound"
2735            );
2736            pages_remaining -= 1;
2737            let page = self
2738                .client
2739                .next_catch_up_page(&cursor, &catch_up.frontier)
2740                .await?;
2741            cursor = self.apply_event_page(page).await?;
2742        }
2743        ensure!(
2744            cursor == catch_up.frontier,
2745            "controller projection did not reach the captured relay frontier"
2746        );
2747        if cursor.ordinal > 0 {
2748            let acknowledged = self
2749                .client
2750                .acknowledge(cursor.ordinal, &cursor.digest)
2751                .await?;
2752            ensure!(
2753                acknowledged == cursor,
2754                "relay acknowledged cursor {}:{} instead of {}:{}",
2755                acknowledged.ordinal,
2756                acknowledged.digest,
2757                cursor.ordinal,
2758                cursor.digest,
2759            );
2760        }
2761        let mut operational = catch_up.state;
2762        operational.acknowledged_through = cursor.ordinal;
2763        operational.acknowledged_digest = cursor.digest;
2764        self.operational = operational;
2765        Ok(())
2766    }
2767
2768    async fn repair_projection(&mut self) -> Result<()> {
2769        let state = hel::hel_database::load_state()?;
2770        let record = state
2771            .sessions
2772            .get(&self.materialized.session_id)
2773            .context("controller session disappeared while repairing its projection")?;
2774        let Some(checkpoint) = record.checkpoint.as_ref() else {
2775            let replacement = MaterializedSession::empty(&self.materialized.session_id);
2776            self.client
2777                .attach(
2778                    replacement.applied_event_ordinal,
2779                    &replacement.applied_event_digest,
2780                )
2781                .await
2782                .context("relay cannot rebuild the projection from its genesis")?;
2783            save_materialized_session(&replacement)?;
2784            self.materialized = replacement;
2785            return Ok(());
2786        };
2787        let checkpoint_path = checkpoint.archive_path.clone();
2788        let archive = tokio::task::spawn_blocking(move || {
2789            verify_archive_streaming(&checkpoint_path).with_context(|| {
2790                format!(
2791                    "verify projection repair checkpoint {}",
2792                    checkpoint_path.display()
2793                )
2794            })
2795        })
2796        .await
2797        .context("projection repair archive verification task failed")??;
2798        ensure!(
2799            archive.archive_sha256 == checkpoint.sha256,
2800            "projection repair checkpoint checksum does not match controller metadata"
2801        );
2802        ensure!(
2803            archive.manifest.session.id == self.materialized.session_id,
2804            "projection repair checkpoint belongs to session {}, not {}",
2805            archive.manifest.session.id,
2806            self.materialized.session_id
2807        );
2808        let canonical = archive.canonical_session;
2809        ensure!(
2810            canonical.event_frontier == checkpoint.event_frontier,
2811            "projection repair checkpoint metadata frontier {} does not match archive frontier {}",
2812            checkpoint.event_frontier,
2813            canonical.event_frontier
2814        );
2815
2816        // Prove that the relay recognizes this exact event-chain cursor before
2817        // replacing any controller state. A matching ordinal alone is not a
2818        // repair proof.
2819        self.client
2820            .attach(canonical.event_frontier, &canonical.event_frontier_digest)
2821            .await
2822            .context("relay rejected the verified checkpoint repair cursor")?;
2823        let replacement =
2824            materialized_session_from_canonical(&self.materialized.session_id, &canonical)?;
2825        save_materialized_session(&replacement)?;
2826        self.materialized = replacement;
2827        Ok(())
2828    }
2829
2830    pub fn snapshot(&self) -> ManagedSessionSnapshot {
2831        ManagedSessionSnapshot {
2832            window: hel::hel_state::ProjectionWindow::of(&self.materialized),
2833            materialized: self.materialized.clone(),
2834            operational: self.operational.clone(),
2835            latest_credential_sync_signal: self.latest_credential_sync_signal.clone(),
2836            worker_build: self.client.worker_build().map(str::to_owned),
2837        }
2838    }
2839
2840    /// Hands one command to the relay and returns the ordinal it accepted it
2841    /// at, without catching the local projection up to it.
2842    ///
2843    /// Callers that need the projection current call [`Self::sync`] after.
2844    /// Keeping the two apart matters on the prompt path: the catch-up is the
2845    /// expensive half, and a caller waiting to hear that the relay took the
2846    /// command should not wait for it. It also stops a failed catch-up from
2847    /// looking like a failed submission to a caller that would retry.
2848    pub async fn submit_accepted(
2849        &mut self,
2850        command_id: String,
2851        command: RelayCommand,
2852    ) -> Result<u64> {
2853        self.client.submit(command_id, command).await
2854    }
2855
2856    pub async fn submit(&mut self, command_id: String, command: RelayCommand) -> Result<u64> {
2857        let ordinal = self.submit_accepted(command_id, command).await?;
2858        self.sync_in_place().await?;
2859        Ok(ordinal)
2860    }
2861
2862    pub async fn respond_elicitation(
2863        &mut self,
2864        elicitation_id: String,
2865        response: ElicitationResponse,
2866    ) -> Result<()> {
2867        self.client
2868            .respond_elicitation(elicitation_id, response)
2869            .await?;
2870        self.sync_in_place().await?;
2871        Ok(())
2872    }
2873
2874    /// Persist relay-private context for the next real prompt. It never
2875    /// contributes an event to the canonical projection.
2876    pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
2877        self.client.install_prompt_context(text).await
2878    }
2879
2880    /// Apply one relay transport page in bounded durable chunks. A transport
2881    /// page can contain thousands of events, but SQLite has one global writer;
2882    /// regularly releasing it lets other session actors keep their views
2883    /// current. The relay GC watermark advances only after the complete page.
2884    async fn apply_event_page(&mut self, page: RelayEventPage) -> Result<RelayCursor> {
2885        for event in &page.events {
2886            if let hel::hel_worker::RelayObservation::CommandQueued {
2887                command: RelayCommand::Prompt { prompt },
2888                ..
2889            } = &event.observation
2890            {
2891                for reference in hel::hel_attachment::references(prompt)? {
2892                    if let Err(error) = self.client.cache_attachment(&reference).await {
2893                        // History remains readable even if a blob was lost. A
2894                        // later submission still verifies every image before
2895                        // admission, and must report missing data to the user.
2896                        tracing::warn!(
2897                            session_id = %self.materialized.session_id,
2898                            attachment = %reference.sha256,
2899                            %error,
2900                            "could not cache image attachment during replay"
2901                        );
2902                    }
2903                }
2904            }
2905        }
2906
2907        let RelayEventPage {
2908            events,
2909            through_ordinal,
2910            through_digest,
2911        } = page;
2912        let event_count = events.len();
2913        let transaction_count = event_count.div_ceil(PROJECTION_TRANSACTION_EVENT_BUDGET);
2914        let started = Instant::now();
2915        for events in events.chunks(PROJECTION_TRANSACTION_EVENT_BUDGET) {
2916            let session_id = self.materialized.session_id.clone();
2917            let events = events.to_vec();
2918            let projection = self.materialized.clone();
2919            // Projection is CPU work and its durable page uses synchronous
2920            // SQLite. Keep both off the async actor runtime so independent
2921            // sessions stay responsive during each bounded catch-up chunk.
2922            let (projection, credential_sync_signal) = tokio::task::spawn_blocking(
2923                move || -> Result<(MaterializedSession, Option<CredentialSyncSignal>)> {
2924                    // The in-memory projection advances on a working copy and
2925                    // is published only once its page is durable.
2926                    let mut projection = projection;
2927                    let mut projection_index = ProjectionIndex::new(&projection);
2928                    let mut credential_sync_signal = None;
2929                    let mut prepared = Vec::with_capacity(events.len());
2930                    for event in &events {
2931                        let mutation =
2932                            project_relay_event_indexed(&projection, &projection_index, event)?
2933                                .mutation;
2934                        prepared.push((
2935                            event.ordinal,
2936                            event.previous_digest.clone(),
2937                            event.digest.clone(),
2938                            mutation.clone(),
2939                        ));
2940                        apply_committed_projection_event_indexed(
2941                            &mut projection,
2942                            &mut projection_index,
2943                            event,
2944                            mutation,
2945                        )?;
2946                        if let Some(reason) = relay_event_credential_sync_reason(event) {
2947                            credential_sync_signal = Some(CredentialSyncSignal {
2948                                ordinal: event.ordinal,
2949                                reason,
2950                            });
2951                        }
2952                    }
2953                    drop(projection_index);
2954                    apply_projection_page(&session_id, move |committed| {
2955                        for (ordinal, previous_digest, digest, mutation) in prepared {
2956                            match committed.apply(ordinal, &previous_digest, &digest, &mutation)? {
2957                                ProjectionApplyOutcome::Applied => {}
2958                                ProjectionApplyOutcome::AlreadyApplied => {
2959                                    return Err(ProjectionAdvancedError {
2960                                        event_ordinal: ordinal,
2961                                    }
2962                                    .into());
2963                                }
2964                            }
2965                        }
2966                        Ok((projection, credential_sync_signal))
2967                    })
2968                },
2969            )
2970            .await
2971            .context("relay projection page task failed")??;
2972            self.materialized = projection;
2973            if let Some(signal) = credential_sync_signal {
2974                self.latest_credential_sync_signal = Some(signal);
2975            }
2976        }
2977        if transaction_count > 1 {
2978            tracing::debug!(
2979                session_id = self.materialized.session_id,
2980                event_count,
2981                transaction_count,
2982                elapsed_ms = started.elapsed().as_millis(),
2983                "applied a large relay page in bounded projection transactions"
2984            );
2985        }
2986        let delivered_through = self.materialized.applied_event_ordinal;
2987        ensure!(
2988            delivered_through == through_ordinal,
2989            "relay page claimed frontier {} but delivered through {delivered_through}",
2990            through_ordinal
2991        );
2992        ensure!(
2993            self.materialized.applied_event_digest == through_digest,
2994            "relay page digest does not match its claimed frontier"
2995        );
2996        Ok(RelayCursor {
2997            ordinal: delivered_through,
2998            digest: self.materialized.applied_event_digest.clone(),
2999        })
3000    }
3001
3002    /// Reconcile this worker's project-memory replica at an explicit durable
3003    /// boundary. Normal relay attachment and polling must never perform this
3004    /// filesystem work: a degraded target could otherwise turn reconnects
3005    /// into an unbounded queue of timed-out snapshot writes.
3006    pub async fn sync_project_memory(&mut self) -> Result<()> {
3007        let Some(target) = self.project_memory.clone() else {
3008            return Ok(());
3009        };
3010        if !self.client.supports_project_memory_sync() {
3011            tracing::warn!(
3012                session_id = self.materialized.session_id,
3013                "worker protocol predates project-memory synchronization; preserving memory through checkpoints only"
3014            );
3015            self.project_memory = None;
3016            return Ok(());
3017        }
3018        let (baseline, replica) = match self.client.project_memory_snapshot().await {
3019            Ok(snapshot) => snapshot,
3020            Err(error)
3021                if error
3022                    .downcast_ref::<RelayRejected>()
3023                    .is_some_and(|rejected| {
3024                        rejected.0.code == hel::hel_worker::RelayErrorCode::InvalidState
3025                    }) =>
3026            {
3027                tracing::warn!(
3028                    session_id = self.materialized.session_id,
3029                    "worker has no project-memory endpoint; preserving memory through checkpoints only"
3030                );
3031                self.project_memory = None;
3032                return Ok(());
3033            }
3034            Err(error) => return Err(error),
3035        };
3036        let canonical_root = target.canonical_root;
3037        let session_id = self.materialized.session_id.clone();
3038        let (reconciliation, worker_install_needed) = tokio::task::spawn_blocking(move || {
3039            let reconciliation = hel::hel_project_memory::reconcile_into_canonical(
3040                &canonical_root,
3041                &baseline,
3042                &replica,
3043                &session_id,
3044            )?;
3045            let worker_install_needed =
3046                reconciliation.merged != baseline || reconciliation.merged != replica;
3047            Ok::<_, anyhow::Error>((reconciliation, worker_install_needed))
3048        })
3049        .await
3050        .context("project memory reconciliation task failed")??;
3051        for conflict in &reconciliation.conflicts {
3052            tracing::warn!(session_id = self.materialized.session_id, %conflict, "project memory conflict preserved");
3053        }
3054        if worker_install_needed {
3055            self.client
3056                .install_project_memory_snapshot(reconciliation.merged)
3057                .await?;
3058        }
3059        Ok(())
3060    }
3061}
3062
3063fn relay_desynchronized(error: &anyhow::Error) -> bool {
3064    error.chain().any(|cause| {
3065        cause
3066            .downcast_ref::<RelayRejected>()
3067            .is_some_and(RelayRejected::is_desynchronized)
3068    })
3069}
3070
3071fn projection_integrity_failure(error: &anyhow::Error) -> bool {
3072    error
3073        .chain()
3074        .any(|cause| cause.downcast_ref::<ProjectionIntegrityError>().is_some())
3075}
3076
3077pub fn new_command_id(prefix: &str) -> Result<String> {
3078    ensure!(!prefix.trim().is_empty(), "command ID prefix is required");
3079    let mut random = [0_u8; 16];
3080    getrandom::fill(&mut random)
3081        .map_err(|error| anyhow::anyhow!("generate command ID: {error}"))?;
3082    Ok(format!("{prefix}-{}", hex(&random)))
3083}
3084
3085fn hex(bytes: &[u8]) -> String {
3086    const DIGITS: &[u8; 16] = b"0123456789abcdef";
3087    let mut output = String::with_capacity(bytes.len() * 2);
3088    for byte in bytes {
3089        output.push(char::from(DIGITS[usize::from(byte >> 4)]));
3090        output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
3091    }
3092    output
3093}
3094
3095/// A stopped actor and the manager that resolves its live replacement.
3096///
3097/// This fixture and its constructor are compiled unconditionally and hidden
3098/// from the documentation because the chat crate's tests need them, and a
3099/// `#[cfg(test)]` item is invisible to another crate.
3100#[doc(hidden)]
3101pub struct ReplacementSessionTestFixture {
3102    pub stopped: ManagedSessionHandle,
3103    pub control: SessionManagerControl,
3104    pub submitted: mpsc::UnboundedReceiver<RelayCommand>,
3105}
3106
3107/// A stopped actor and a manager that resolves its live replacement. Chat
3108/// tests use this hand-written actor instead of mocking the session manager
3109/// protocol.
3110#[doc(hidden)]
3111pub fn replacement_session_test_fixture(
3112    session_id: &str,
3113    accepted_ordinal: u64,
3114) -> ReplacementSessionTestFixture {
3115    let (stopped_commands, stopped_commands_rx) = mpsc::channel(1);
3116    drop(stopped_commands_rx);
3117    let (stopped_releases, stopped_releases_rx) = mpsc::unbounded_channel();
3118    drop(stopped_releases_rx);
3119    let (stopped_view_tx, stopped_view) = watch::channel(ManagedSessionView::default());
3120    drop(stopped_view_tx);
3121    let stopped = ManagedSessionHandle {
3122        session_id: session_id.to_owned(),
3123        commands: stopped_commands,
3124        releases: stopped_releases,
3125        view: stopped_view,
3126    };
3127
3128    let (commands, mut commands_rx) = mpsc::channel(4);
3129    let (releases, _releases_rx) = mpsc::unbounded_channel();
3130    let (view_tx, view) = watch::channel(ManagedSessionView::default());
3131    let replacement = ManagedSessionHandle {
3132        session_id: session_id.to_owned(),
3133        commands,
3134        releases,
3135        view,
3136    };
3137    let actor_session_id = session_id.to_owned();
3138    let (submitted_tx, submitted) = mpsc::unbounded_channel();
3139    tokio::spawn(async move {
3140        let _view_tx = view_tx;
3141        while let Some(command) = commands_rx.recv().await {
3142            match command {
3143                ActorCommand::Submit { command, reply, .. } => {
3144                    // Tests can drop the optional observer when they only
3145                    // care about acceptance/reconnection.
3146                    let _ = submitted_tx.send(command);
3147                    let _ = reply.send(Ok(accepted_ordinal));
3148                }
3149                ActorCommand::Sync { reply } => {
3150                    let _ = reply.send(Ok(()));
3151                }
3152                command => command.reject(&actor_session_id, "unsupported test operation"),
3153            }
3154        }
3155    });
3156
3157    let (manager_commands, mut manager_commands_rx) = mpsc::channel(4);
3158    let manager_replacement = replacement.clone();
3159    tokio::spawn(async move {
3160        while let Some(ManagerCommand::Session {
3161            session_id: requested,
3162            reply,
3163        }) = manager_commands_rx.recv().await
3164        {
3165            let resolved =
3166                (requested == manager_replacement.session_id).then(|| manager_replacement.clone());
3167            let _ = reply.send(resolved);
3168        }
3169    });
3170    ReplacementSessionTestFixture {
3171        stopped,
3172        submitted,
3173        control: SessionManagerControl {
3174            commands: manager_commands,
3175        },
3176    }
3177}
3178
3179#[cfg(test)]
3180mod tests {
3181    use super::*;
3182
3183    #[tokio::test]
3184    async fn session_adoption_deadline_also_bounds_an_unanswered_manager_request() {
3185        let (commands, mut requests) = mpsc::channel(1);
3186        let control = SessionManagerControl { commands };
3187        let request = tokio::spawn(async move {
3188            control
3189                .wait_for_session("muse", Duration::from_millis(20))
3190                .await
3191        });
3192        let ManagerCommand::Session { mut reply, .. } = requests.recv().await.unwrap();
3193        // Retain the reply without answering, like an unresponsive manager.
3194        let error = tokio::time::timeout(Duration::from_secs(1), request)
3195            .await
3196            .expect("the adoption deadline must bound an individual request")
3197            .unwrap()
3198            .unwrap_err();
3199        assert!(error.to_string().contains("did not become available"));
3200        reply.closed().await;
3201    }
3202    #[cfg(unix)]
3203    use agent_client_protocol::schema::v1::{ContentBlock, TextContent};
3204    use sha2::Digest;
3205
3206    fn ordering_request(session_id: &str, command_id: &str) -> RemoteSessionRequest {
3207        let (reply, _response) = oneshot::channel();
3208        RemoteSessionRequest::Submit {
3209            session_id: session_id.into(),
3210            command_id: command_id.into(),
3211            command: RelayCommand::SetConfig {
3212                key: "effort".into(),
3213                value: "high".into(),
3214            },
3215            admission: None,
3216            reply,
3217        }
3218    }
3219
3220    /// `/effort` followed by a prompt has to reach the relay that way round,
3221    /// or the prompt runs under the old setting. A bridge that spawns every
3222    /// request concurrently loses that, so the order is pinned here: the
3223    /// first request is held up, and the second must not overtake it.
3224    #[tokio::test]
3225    async fn one_session_keeps_its_requests_in_the_order_they_were_made() {
3226        let observed = Arc::new(Mutex::new(Vec::new()));
3227        let release = Arc::new(tokio::sync::Notify::new());
3228        let mut order = SessionRequestOrder::new();
3229
3230        for command_id in ["first", "second", "third"] {
3231            let observed = Arc::clone(&observed);
3232            let release = Arc::clone(&release);
3233            order.dispatch(ordering_request("session-a", command_id), move |request| {
3234                let RemoteSessionRequest::Submit { command_id, .. } = request else {
3235                    unreachable!("the fixture only submits")
3236                };
3237                async move {
3238                    // Only the first request waits. If the order were lost,
3239                    // the other two would finish while it is held.
3240                    if command_id == "first" {
3241                        release.notified().await;
3242                    }
3243                    observed.lock().unwrap().push(command_id);
3244                }
3245            });
3246        }
3247
3248        // Nothing may run while the first request is held. Yield generously:
3249        // the point is that the later requests never get to run, not that
3250        // they have not been polled yet.
3251        for _ in 0..64 {
3252            tokio::task::yield_now().await;
3253        }
3254        assert!(
3255            observed.lock().unwrap().is_empty(),
3256            "a later request overtook the one being held: {:?}",
3257            observed.lock().unwrap()
3258        );
3259
3260        release.notify_one();
3261        tokio::time::timeout(std::time::Duration::from_secs(5), async {
3262            while observed.lock().unwrap().len() < 3 {
3263                tokio::task::yield_now().await;
3264            }
3265        })
3266        .await
3267        .expect("every request ran");
3268        assert_eq!(*observed.lock().unwrap(), ["first", "second", "third"]);
3269    }
3270
3271    /// Ordering is per session: one session waiting on a slow relay must not
3272    /// hold up another session's prompt.
3273    #[tokio::test]
3274    async fn different_sessions_still_overlap() {
3275        let finished = Arc::new(Mutex::new(Vec::new()));
3276        let release = Arc::new(tokio::sync::Notify::new());
3277        let mut order = SessionRequestOrder::new();
3278
3279        let held = Arc::clone(&release);
3280        let recorder = Arc::clone(&finished);
3281        order.dispatch(ordering_request("session-a", "slow"), move |_| async move {
3282            held.notified().await;
3283            recorder.lock().unwrap().push("slow");
3284        });
3285        let recorder = Arc::clone(&finished);
3286        order.dispatch(ordering_request("session-b", "fast"), move |_| async move {
3287            recorder.lock().unwrap().push("fast");
3288        });
3289
3290        tokio::time::timeout(std::time::Duration::from_secs(5), async {
3291            while finished.lock().unwrap().is_empty() {
3292                tokio::task::yield_now().await;
3293            }
3294        })
3295        .await
3296        .expect("the other session ran while the first was held");
3297        assert_eq!(*finished.lock().unwrap(), ["fast"]);
3298
3299        release.notify_one();
3300        tokio::time::timeout(std::time::Duration::from_secs(5), async {
3301            while finished.lock().unwrap().len() < 2 {
3302                tokio::task::yield_now().await;
3303            }
3304        })
3305        .await
3306        .expect("the held request ran once released");
3307    }
3308
3309    #[tokio::test]
3310    async fn slow_reviewer_does_not_delay_primary_or_another_role_at_the_bridge() {
3311        let mut order = SessionRequestOrder::new();
3312        let release = Arc::new(tokio::sync::Notify::new());
3313        let (done, mut received) = mpsc::unbounded_channel();
3314        let reviewer = |role: &str| RemoteSessionRequest::Reviewer {
3315            session_id: "one-session".to_owned(),
3316            role: Some(role.to_owned()),
3317            action: ReviewerAction::Pause,
3318            reply: oneshot::channel().0,
3319        };
3320        let held = release.clone();
3321        order.dispatch(
3322            reviewer("slow"),
3323            move |_| async move { held.notified().await },
3324        );
3325        let done_role = done.clone();
3326        order.dispatch(reviewer("other"), move |_| async move {
3327            done_role.send("other").unwrap();
3328        });
3329        order.dispatch(
3330            ordering_request("one-session", "prompt"),
3331            move |_| async move {
3332                done.send("primary").unwrap();
3333            },
3334        );
3335        let first = tokio::time::timeout(Duration::from_secs(2), received.recv())
3336            .await
3337            .unwrap()
3338            .unwrap();
3339        let second = tokio::time::timeout(Duration::from_secs(2), received.recv())
3340            .await
3341            .unwrap()
3342            .unwrap();
3343        assert_ne!(first, second);
3344        release.notify_one();
3345    }
3346
3347    /// A session that has gone quiet must not leave a handle behind for ever:
3348    /// a long-lived daemon serves many sessions.
3349    #[tokio::test]
3350    async fn finished_sessions_are_forgotten() {
3351        let mut order = SessionRequestOrder::new();
3352        for index in 0..8 {
3353            order.dispatch(
3354                ordering_request(&format!("session-{index}"), "only"),
3355                |_| async {},
3356            );
3357            tokio::time::timeout(std::time::Duration::from_secs(5), async {
3358                while order.latest.values().any(|handle| !handle.is_finished()) {
3359                    tokio::task::yield_now().await;
3360                }
3361            })
3362            .await
3363            .expect("the request finished");
3364        }
3365        // The next dispatch prunes what has finished, so the map tracks live
3366        // work rather than every session ever seen.
3367        order.dispatch(ordering_request("session-last", "only"), |_| async {});
3368        assert_eq!(order.latest.len(), 1);
3369    }
3370
3371    /// A reviewer action reaches a remote controller daemon as JSON, so both
3372    /// halves of the exchange have to survive that round trip intact.
3373    #[test]
3374    fn reviewer_actions_and_outcomes_survive_the_daemon_wire() {
3375        let config = ReviewerLaunchConfig {
3376            profile_id: "claude".into(),
3377            harness: hel::hel_config::HarnessKind::Claude,
3378            bridge_command: "npx".into(),
3379            bridge_args: vec!["claude-code-acp".into()],
3380            environment: BTreeMap::from([("EXTRA".into(), "1".into())]),
3381            execution_policy: hel::hel_config::ExecutionPolicy::Unconstrained,
3382            model: Some("sonnet".into()),
3383            effort: Some("high".into()),
3384            generation: 2,
3385            mcp_servers: Vec::new(),
3386        };
3387        let actions = [
3388            ReviewerAction::Start {
3389                config: Box::new(config),
3390            },
3391            ReviewerAction::Submit {
3392                command_id: "review-1".into(),
3393                command: RelayCommand::Cancel,
3394            },
3395            ReviewerAction::Attach {
3396                after_ordinal: 4,
3397                after_digest: "digest".into(),
3398            },
3399            ReviewerAction::Acknowledge {
3400                through_ordinal: 4,
3401                through_digest: "digest".into(),
3402            },
3403            ReviewerAction::Status,
3404            ReviewerAction::Pause,
3405            ReviewerAction::CaptureDelta {
3406                baselines: BTreeMap::from([(std::path::PathBuf::from("/w/app"), "tree".into())]),
3407            },
3408            ReviewerAction::AdvanceBaseline {
3409                trees: BTreeMap::from([(std::path::PathBuf::from("/w/app"), "tree".into())]),
3410            },
3411            ReviewerAction::AnalyzeDelta {
3412                repositories: vec![hel::hel_worker::AnalyzeDeltaRepository {
3413                    root: std::path::PathBuf::from("/w/app"),
3414                    baseline_tree: Some("base".into()),
3415                    current_tree: "target".into(),
3416                }],
3417            },
3418        ];
3419        for action in actions {
3420            let encoded = serde_json::to_string(&action).unwrap();
3421            let decoded: ReviewerAction = serde_json::from_str(&encoded).unwrap();
3422            assert_eq!(decoded, action);
3423        }
3424
3425        let outcome = ReviewerOutcome::Accepted { ordinal: 9 };
3426        let encoded = serde_json::to_string(&outcome).unwrap();
3427        let decoded: ReviewerOutcome = serde_json::from_str(&encoded).unwrap();
3428        assert!(matches!(decoded, ReviewerOutcome::Accepted { ordinal: 9 }));
3429
3430        let paused = serde_json::to_string(&ReviewerOutcome::Paused).unwrap();
3431        assert!(matches!(
3432            serde_json::from_str::<ReviewerOutcome>(&paused).unwrap(),
3433            ReviewerOutcome::Paused
3434        ));
3435
3436        let delta = ReviewerOutcome::Delta {
3437            repositories: vec![hel::hel_worker::RepoDelta {
3438                root: std::path::PathBuf::from("/w/app"),
3439                baseline_tree: None,
3440                current_tree: "target".into(),
3441                patch: "diff --git a/a b/a\n".into(),
3442                diffstat: "1 file changed".into(),
3443                changed_lines: 1,
3444            }],
3445        };
3446        let encoded = serde_json::to_string(&delta).unwrap();
3447        let ReviewerOutcome::Delta { repositories } =
3448            serde_json::from_str::<ReviewerOutcome>(&encoded).unwrap()
3449        else {
3450            panic!("a captured delta must survive the daemon wire");
3451        };
3452        assert_eq!(repositories.len(), 1);
3453        assert_eq!(repositories[0].current_tree, "target");
3454    }
3455
3456    /// Every reviewer action names itself for the actor's logs and for the
3457    /// rejection path, so a stalled review can be traced to the step it stalled
3458    /// on.
3459    #[test]
3460    fn every_reviewer_action_names_its_operation() {
3461        let names = [
3462            ReviewerAction::Submit {
3463                command_id: String::new(),
3464                command: RelayCommand::Cancel,
3465            }
3466            .operation_name(),
3467            ReviewerAction::Attach {
3468                after_ordinal: 0,
3469                after_digest: String::new(),
3470            }
3471            .operation_name(),
3472            ReviewerAction::Acknowledge {
3473                through_ordinal: 0,
3474                through_digest: String::new(),
3475            }
3476            .operation_name(),
3477            ReviewerAction::Status.operation_name(),
3478            ReviewerAction::Pause.operation_name(),
3479        ];
3480        assert_eq!(
3481            names,
3482            [
3483                "reviewer_submit",
3484                "reviewer_attach",
3485                "reviewer_acknowledge",
3486                "reviewer_status",
3487                "reviewer_pause",
3488            ]
3489        );
3490        assert!(names.iter().all(|name| name.starts_with("reviewer_")));
3491    }
3492
3493    #[test]
3494    fn reconnect_delay_backs_off_and_stops_at_the_ceiling() {
3495        assert_eq!(reconnect_delay(1), RECONNECT_INTERVAL);
3496        assert_eq!(reconnect_delay(2), Duration::from_secs(2));
3497        assert_eq!(reconnect_delay(4), Duration::from_secs(8));
3498        assert_eq!(reconnect_delay(6), RECONNECT_BACKOFF_CEILING);
3499        assert_eq!(reconnect_delay(u32::MAX), RECONNECT_BACKOFF_CEILING);
3500    }
3501
3502    #[test]
3503    fn only_dead_worker_connection_failures_request_a_restart() {
3504        // The wording is deliberately unlike anything a matcher could have
3505        // been written against: the marker, not the message, decides.
3506        let reworded = anyhow::Error::new(RelayTransportDead::new(
3507            "the session proxy vanished mid-conversation",
3508        ))
3509        .context("connect to the session worker for checkpoint");
3510        assert!(worker_connect_needs_restart(&reworded), "{reworded:#}");
3511        assert!(!worker_connect_allows_live_restart(&reworded));
3512
3513        // Text alone proves nothing now, not even the exact text the producing
3514        // sites still use: an unmarked failure must never restart a worker.
3515        for detail in [
3516            "relay proxy disconnected during hello",
3517            "Connection refused (os error 111)",
3518            "relay negotiated unsupported protocol 9",
3519            "controller projection is corrupt",
3520        ] {
3521            assert!(!worker_connect_needs_restart(&anyhow::anyhow!(detail)));
3522        }
3523    }
3524
3525    /// The producing side of the same contract: a proxy that dies without
3526    /// serving the handshake must ask for a worker restart, whatever its
3527    /// failure happens to read like.
3528    #[cfg(unix)]
3529    #[tokio::test]
3530    async fn a_proxy_that_dies_before_hello_requests_a_worker_restart() {
3531        let mut dead = target("sh");
3532        dead.spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("dead relay proxy fixture");
3533
3534        let error = StandaloneSession::connect(&dead)
3535            .await
3536            .err()
3537            .expect("a proxy that exits cannot serve a session");
3538
3539        assert!(worker_connect_needs_restart(&error), "{error:#}");
3540        assert!(worker_connect_allows_live_restart(&error));
3541    }
3542
3543    /// A lease answer crosses a channel. Formatting the failure into a string
3544    /// there would strip the cause and silently cost the checkpoint path its
3545    /// restart decision, so prove the typed cause survives the handoff.
3546    #[cfg(unix)]
3547    #[tokio::test]
3548    async fn a_failed_lease_keeps_the_cause_that_decides_a_worker_restart() {
3549        let (commands_tx, commands_rx) = mpsc::channel(4);
3550        let (_releases_tx, releases_rx) = mpsc::unbounded_channel();
3551        let (_retirement_tx, retirement_rx) = watch::channel(false);
3552        let (view_tx, _view_rx) = watch::channel(ManagedSessionView::default());
3553        let (updates_tx, _updates_rx) = coalesced_update_channel();
3554        let mut dead = target("sh");
3555        dead.spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("dead relay proxy fixture");
3556        tokio::spawn(run_session_actor(
3557            dead,
3558            commands_rx,
3559            releases_rx,
3560            retirement_rx,
3561            view_tx,
3562            updates_tx,
3563        ));
3564
3565        let (reply, response) = oneshot::channel();
3566        commands_tx
3567            .send(ActorCommand::Lease { reply })
3568            .await
3569            .unwrap();
3570        let error = response
3571            .await
3572            .expect("actor answered the lease request")
3573            .err()
3574            .expect("a dead proxy cannot be leased");
3575
3576        assert!(worker_connect_needs_restart(&error), "{error:#}");
3577    }
3578
3579    #[tokio::test]
3580    async fn recovery_restarts_a_live_worker_only_after_a_failed_handshake() {
3581        let directory = tempfile::tempdir().unwrap();
3582        let restarted = directory.path().join("restarted");
3583        let recovery = |liveness: &str| WorkerRecoveryPlan {
3584            target: None,
3585            workspace: Some(WorkerWorkspace {
3586                target: hel::hel_state::ManagedWorktreeTarget::Local,
3587                directory: directory.path().to_path_buf(),
3588            }),
3589            liveness_probe: CommandSpec::new("printf", [format!("{liveness}\n")])
3590                .purpose("probe test worker liveness"),
3591            binary_refresh: None,
3592            launch_refresh: None,
3593            restart: CommandPlan {
3594                description: "restart test worker".into(),
3595                commands: vec![
3596                    CommandSpec::new("touch", [restarted.to_string_lossy().into_owned()])
3597                        .purpose("restart test worker"),
3598                ],
3599            },
3600        };
3601
3602        assert_eq!(
3603            recover_worker(recovery("alive"), false).await.unwrap(),
3604            WorkerRecoveryOutcome::Alive
3605        );
3606        assert!(!restarted.exists(), "a live worker must not be restarted");
3607
3608        assert_eq!(
3609            recover_worker(recovery("starting"), true).await.unwrap(),
3610            WorkerRecoveryOutcome::Starting
3611        );
3612        assert!(
3613            !restarted.exists(),
3614            "a worker recovering its journal must not be restarted"
3615        );
3616
3617        assert_eq!(
3618            recover_worker(recovery("alive"), true).await.unwrap(),
3619            WorkerRecoveryOutcome::RestartedUnresponsive
3620        );
3621        assert!(
3622            restarted.exists(),
3623            "a worker that cannot serve a fresh handshake is restarted"
3624        );
3625        std::fs::remove_file(&restarted).unwrap();
3626
3627        assert_eq!(
3628            recover_worker(recovery("dead"), false).await.unwrap(),
3629            WorkerRecoveryOutcome::RestartedDead
3630        );
3631        assert!(restarted.exists(), "a confirmed dead worker is restarted");
3632    }
3633
3634    #[tokio::test]
3635    async fn recovery_reports_a_missing_bare_workspace_without_restarting() {
3636        let directory = tempfile::tempdir().unwrap();
3637        let missing = directory.path().join("removed-worktree");
3638        let restarted = directory.path().join("worker-restarted");
3639        let plan = WorkerRecoveryPlan {
3640            target: None,
3641            workspace: Some(WorkerWorkspace {
3642                target: hel::hel_state::ManagedWorktreeTarget::Local,
3643                directory: missing.clone(),
3644            }),
3645            liveness_probe: CommandSpec::new("printf", ["dead\n"])
3646                .purpose("probe test worker liveness"),
3647            binary_refresh: None,
3648            launch_refresh: None,
3649            restart: CommandPlan {
3650                description: "must not restart missing workspace worker".into(),
3651                commands: vec![
3652                    CommandSpec::new("touch", [restarted.to_string_lossy().into_owned()])
3653                        .purpose("restart test worker"),
3654                ],
3655            },
3656        };
3657
3658        assert_eq!(
3659            recover_worker(plan, false).await.unwrap(),
3660            WorkerRecoveryOutcome::WorkspaceMissing(missing.clone())
3661        );
3662        assert!(
3663            !restarted.exists(),
3664            "a missing workspace must not be restarted"
3665        );
3666    }
3667
3668    #[tokio::test]
3669    async fn recovery_replaces_only_a_stale_worker_binary_before_restart() {
3670        let directory = tempfile::tempdir().unwrap();
3671        let source = directory.path().join("current-worker");
3672        let refreshed = directory.path().join("worker-refreshed");
3673        let restarted = directory.path().join("worker-restarted");
3674        std::fs::write(&source, b"current worker binary").unwrap();
3675        let current_digest = format!("{:x}", sha2::Sha256::digest(b"current worker binary"));
3676        let recovery = |installed_digest: &str, require_refresh: bool| {
3677            let mut restart = if require_refresh {
3678                CommandSpec::new(
3679                    "sh",
3680                    [
3681                        "-c",
3682                        "test -f \"$MJ_TEST_REFRESHED\" && touch -- \"$MJ_TEST_RESTARTED\"",
3683                    ],
3684                )
3685            } else {
3686                CommandSpec::new("touch", [restarted.to_string_lossy().into_owned()])
3687            }
3688            .purpose("restart test worker");
3689            restart.env.insert(
3690                "MJ_TEST_REFRESHED".into(),
3691                refreshed.to_string_lossy().into_owned(),
3692            );
3693            restart.env.insert(
3694                "MJ_TEST_RESTARTED".into(),
3695                restarted.to_string_lossy().into_owned(),
3696            );
3697            WorkerRecoveryPlan {
3698                target: None,
3699                workspace: None,
3700                liveness_probe: CommandSpec::new("printf", ["dead\n"])
3701                    .purpose("probe test worker liveness"),
3702                binary_refresh: Some(WorkerBinaryRefresh::Prepared(WorkerBinaryRefreshPlan {
3703                    source: source.clone(),
3704                    installed_digest: CommandSpec::new(
3705                        "printf",
3706                        [format!("{installed_digest}  /worker/hel\n")],
3707                    )
3708                    .purpose("identify test worker binary"),
3709                    replace: CommandPlan {
3710                        description: "refresh test worker".into(),
3711                        commands: vec![
3712                            CommandSpec::new("touch", [refreshed.to_string_lossy().into_owned()])
3713                                .purpose("refresh test worker"),
3714                        ],
3715                    },
3716                })),
3717                launch_refresh: None,
3718                restart: CommandPlan {
3719                    description: "restart test worker".into(),
3720                    commands: vec![restart],
3721                },
3722            }
3723        };
3724
3725        assert_eq!(
3726            recover_worker(recovery(&current_digest, false), false)
3727                .await
3728                .unwrap(),
3729            WorkerRecoveryOutcome::RestartedDead
3730        );
3731        assert!(!refreshed.exists(), "a current binary must not be copied");
3732        assert!(restarted.exists());
3733
3734        std::fs::remove_file(&restarted).unwrap();
3735        assert_eq!(
3736            recover_worker(recovery(&"0".repeat(64), true), false)
3737                .await
3738                .unwrap(),
3739            WorkerRecoveryOutcome::RestartedDead
3740        );
3741        assert!(refreshed.exists(), "a stale binary must be refreshed");
3742        assert!(restarted.exists(), "refresh must finish before restart");
3743    }
3744
3745    #[tokio::test]
3746    async fn recovery_refreshes_a_stale_launch_config_before_restart() {
3747        let directory = tempfile::tempdir().unwrap();
3748        let refreshed = directory.path().join("launch-refreshed");
3749        let restarted = directory.path().join("worker-restarted");
3750        let mut restart = CommandSpec::new(
3751            "sh",
3752            [
3753                "-c",
3754                "test -f \"$MJ_TEST_REFRESHED\" && touch -- \"$MJ_TEST_RESTARTED\"",
3755            ],
3756        )
3757        .purpose("restart test worker");
3758        restart.env.insert(
3759            "MJ_TEST_REFRESHED".into(),
3760            refreshed.to_string_lossy().into_owned(),
3761        );
3762        restart.env.insert(
3763            "MJ_TEST_RESTARTED".into(),
3764            restarted.to_string_lossy().into_owned(),
3765        );
3766        let outcome = recover_worker(
3767            WorkerRecoveryPlan {
3768                target: None,
3769                workspace: None,
3770                liveness_probe: CommandSpec::new("printf", ["dead\n"])
3771                    .purpose("probe test worker liveness"),
3772                binary_refresh: None,
3773                launch_refresh: Some(WorkerLaunchRefreshPlan {
3774                    expected_sha256: "a".repeat(64),
3775                    installed_digest: CommandSpec::new(
3776                        "printf",
3777                        [format!("{}  /worker/launch.json\n", "b".repeat(64))],
3778                    )
3779                    .purpose("identify test launch config"),
3780                    replace: CommandPlan {
3781                        description: "refresh test launch config".into(),
3782                        commands: vec![
3783                            CommandSpec::new("touch", [refreshed.to_string_lossy().into_owned()])
3784                                .purpose("refresh test launch config"),
3785                        ],
3786                    },
3787                }),
3788                restart: CommandPlan {
3789                    description: "restart test worker".into(),
3790                    commands: vec![restart],
3791                },
3792            },
3793            false,
3794        )
3795        .await
3796        .unwrap();
3797
3798        assert_eq!(outcome, WorkerRecoveryOutcome::RestartedDead);
3799        assert!(refreshed.exists());
3800        assert!(
3801            restarted.exists(),
3802            "config refresh must finish before restart"
3803        );
3804    }
3805
3806    #[tokio::test]
3807    async fn recovery_starts_a_stopped_target_before_probing_its_worker() {
3808        let directory = tempfile::tempdir().unwrap();
3809        let target_started = directory.path().join("target-started");
3810        let worker_restarted = directory.path().join("worker-restarted");
3811        let inspection = |status: &str| {
3812            serde_json::to_string(&serde_json::json!([{
3813                "Config": { "Labels": {
3814                    (hel::hel_targets::MANAGED_LABEL): "true",
3815                    (hel::hel_targets::SESSION_LABEL): "session-1",
3816                }},
3817                "State": { "Status": status },
3818            }]))
3819            .unwrap()
3820        };
3821        let mut inspect = CommandSpec::new(
3822            "sh",
3823            [
3824                "-c",
3825                "if [ -f \"$MJ_TEST_TARGET_STARTED\" ]; then printf '%s\\n' \"$MJ_TEST_RUNNING\"; else printf '%s\\n' \"$MJ_TEST_EXITED\"; fi",
3826            ],
3827        )
3828        .purpose("inspect test target");
3829        inspect.env.insert(
3830            "MJ_TEST_TARGET_STARTED".into(),
3831            target_started.to_string_lossy().into_owned(),
3832        );
3833        inspect
3834            .env
3835            .insert("MJ_TEST_RUNNING".into(), inspection("running"));
3836        inspect
3837            .env
3838            .insert("MJ_TEST_EXITED".into(), inspection("exited"));
3839        let mut start = CommandSpec::new("sh", ["-c", "touch -- \"$MJ_TEST_TARGET_STARTED\""])
3840            .purpose("start test target");
3841        start.env.insert(
3842            "MJ_TEST_TARGET_STARTED".into(),
3843            target_started.to_string_lossy().into_owned(),
3844        );
3845        let mut liveness = CommandSpec::new(
3846            "sh",
3847            [
3848                "-c",
3849                "test -f \"$MJ_TEST_TARGET_STARTED\" && printf 'dead\\n'",
3850            ],
3851        )
3852        .purpose("probe test worker after target start");
3853        liveness.env.insert(
3854            "MJ_TEST_TARGET_STARTED".into(),
3855            target_started.to_string_lossy().into_owned(),
3856        );
3857
3858        let outcome = recover_worker(
3859            WorkerRecoveryPlan {
3860                target: Some(TargetRecoveryPlan {
3861                    exists: CommandSpec::new("true", std::iter::empty::<&str>())
3862                        .purpose("check test target"),
3863                    inspect,
3864                    start,
3865                    session_id: "session-1".into(),
3866                }),
3867                workspace: None,
3868                liveness_probe: liveness,
3869                binary_refresh: None,
3870                launch_refresh: None,
3871                restart: CommandPlan {
3872                    description: "restart test worker".into(),
3873                    commands: vec![
3874                        CommandSpec::new(
3875                            "touch",
3876                            [worker_restarted.to_string_lossy().into_owned()],
3877                        )
3878                        .purpose("restart test worker"),
3879                    ],
3880                },
3881            },
3882            false,
3883        )
3884        .await
3885        .unwrap();
3886
3887        assert_eq!(outcome, WorkerRecoveryOutcome::RestartedDead);
3888        assert!(target_started.exists());
3889        assert!(worker_restarted.exists());
3890    }
3891
3892    #[tokio::test]
3893    async fn recovery_reports_a_missing_target_without_running_worker_commands() {
3894        let unreachable = CommandSpec::new("false", std::iter::empty::<&str>());
3895        let outcome = recover_worker(
3896            WorkerRecoveryPlan {
3897                target: Some(TargetRecoveryPlan {
3898                    exists: unreachable,
3899                    inspect: CommandSpec::new("false", std::iter::empty::<&str>()),
3900                    start: CommandSpec::new("false", std::iter::empty::<&str>()),
3901                    session_id: "session-1".into(),
3902                }),
3903                workspace: None,
3904                liveness_probe: CommandSpec::new("false", std::iter::empty::<&str>()),
3905                binary_refresh: None,
3906                launch_refresh: None,
3907                restart: CommandPlan {
3908                    description: "must not restart".into(),
3909                    commands: vec![CommandSpec::new("false", std::iter::empty::<&str>())],
3910                },
3911            },
3912            true,
3913        )
3914        .await
3915        .unwrap();
3916
3917        assert_eq!(outcome, WorkerRecoveryOutcome::TargetMissing);
3918    }
3919
3920    fn target(program: &str) -> RelaySessionTarget {
3921        RelaySessionTarget {
3922            session_id: "session-1".to_owned(),
3923            spec: CommandSpec::new(program, std::iter::empty::<&str>()),
3924            worker_recovery: None,
3925            project_memory: None,
3926        }
3927    }
3928
3929    /// A connected view carrying a conversation, so republishing it exercises
3930    /// the case a whole-transcript comparison would have to walk.
3931    fn view_at_ordinal(ordinal: u64) -> ManagedSessionView {
3932        let digest = "a".repeat(64);
3933        let mut materialized = MaterializedSession::empty("session-1");
3934        materialized.applied_event_ordinal = ordinal;
3935        materialized.applied_event_digest = digest.clone();
3936        materialized.transcript = (1..=200)
3937            .map(|position| {
3938                Arc::new(hel::hel_state::TranscriptItem {
3939                    stable_id: format!("system:{position}"),
3940                    position,
3941                    latest_content_event_ordinal: None,
3942                    created_at_ms: 1,
3943                    last_changed_at_ms: 1,
3944                    body: hel::hel_state::TranscriptBody::System {
3945                        text: format!("event {position}"),
3946                    },
3947                })
3948            })
3949            .collect();
3950        ManagedSessionView {
3951            snapshot: Some(ManagedSessionSnapshot {
3952                window: hel::hel_state::ProjectionWindow::of(&materialized),
3953                materialized,
3954                operational: RelayOperationalState {
3955                    activity_turn_started_at_ms: None,
3956                    store_id: None,
3957                    idle_since_ms: None,
3958                    session_id: "session-1".into(),
3959                    execution: hel::hel_worker::RelayExecutionState::Idle,
3960                    latest_ordinal: ordinal,
3961                    latest_digest: digest.clone(),
3962                    acknowledged_through: ordinal,
3963                    acknowledged_digest: digest,
3964                    recovery_floor_ordinal: 0,
3965                    recovery_floor_digest: hel::hel_worker::RELAY_EVENT_GENESIS_DIGEST.into(),
3966                    native_session_id: None,
3967                    acp_ready: None,
3968                    agent_capabilities: None,
3969                    agent_info: None,
3970                    steering_supported: None,
3971                    config_options: Vec::new(),
3972                    modes: None,
3973                    available_commands: Vec::new(),
3974                    config: BTreeMap::new(),
3975                    active_prompt: None,
3976                    queued_prompts: Vec::new(),
3977                    active_user_shells: Vec::new(),
3978                    active_agent_terminals: Vec::new(),
3979                    checkpoint_barrier: None,
3980                    checkpoint_ready: None,
3981                    last_acp_activity_at_ms: None,
3982                    current_step_started_at_ms: None,
3983                    foreground_tool_started_at_ms: None,
3984                    harness_turn: None,
3985                    last_harness_turn_started_ordinal: None,
3986                    background_commands: Vec::new(),
3987                },
3988                latest_credential_sync_signal: None,
3989                worker_build: None,
3990            }),
3991            connected: true,
3992            error: None,
3993        }
3994    }
3995
3996    #[test]
3997    fn republishing_an_unchanged_view_notifies_nobody() {
3998        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
3999        let (updates_tx, mut updates_rx) = coalesced_update_channel();
4000
4001        publish_view("session-1", view_at_ordinal(7), &view_tx, &updates_tx);
4002        assert!(view_rx.has_changed().expect("watch stays open"));
4003        assert_eq!(
4004            updates_rx.try_recv().expect("the first view is news").view,
4005            view_at_ordinal(7)
4006        );
4007        let _ = view_rx.borrow_and_update();
4008
4009        publish_view("session-1", view_at_ordinal(7), &view_tx, &updates_tx);
4010
4011        assert!(
4012            !view_rx.has_changed().expect("watch stays open"),
4013            "a sync tick that moved nothing must not wake the dashboard"
4014        );
4015        assert!(updates_rx.try_recv().is_err());
4016    }
4017
4018    #[test]
4019    fn publishing_an_advanced_event_frontier_notifies_watchers() {
4020        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
4021        let (updates_tx, mut updates_rx) = coalesced_update_channel();
4022        publish_view("session-1", view_at_ordinal(7), &view_tx, &updates_tx);
4023        let _ = updates_rx.try_recv();
4024        let _ = view_rx.borrow_and_update();
4025
4026        publish_view("session-1", view_at_ordinal(8), &view_tx, &updates_tx);
4027
4028        assert!(view_rx.has_changed().expect("watch stays open"));
4029        let update = updates_rx.try_recv().expect("the advance is news");
4030        assert_eq!(update.session_id, "session-1");
4031        assert_eq!(
4032            update
4033                .view
4034                .snapshot
4035                .expect("published snapshot")
4036                .materialized
4037                .applied_event_ordinal,
4038            8
4039        );
4040    }
4041
4042    #[test]
4043    fn publishing_relay_state_that_moved_without_the_frontier_notifies_watchers() {
4044        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
4045        let (updates_tx, mut updates_rx) = coalesced_update_channel();
4046        publish_view("session-1", view_at_ordinal(7), &view_tx, &updates_tx);
4047        let _ = updates_rx.try_recv();
4048        let _ = view_rx.borrow_and_update();
4049
4050        let mut view = view_at_ordinal(7);
4051        view.snapshot
4052            .as_mut()
4053            .expect("published snapshot")
4054            .operational
4055            .execution = hel::hel_worker::RelayExecutionState::Running;
4056        publish_view("session-1", view, &view_tx, &updates_tx);
4057
4058        assert!(view_rx.has_changed().expect("watch stays open"));
4059        assert!(updates_rx.try_recv().is_ok());
4060    }
4061
4062    #[test]
4063    fn losing_the_relay_republishes_the_same_snapshot_as_disconnected() {
4064        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
4065        let (updates_tx, mut updates_rx) = coalesced_update_channel();
4066        publish_view("session-1", view_at_ordinal(7), &view_tx, &updates_tx);
4067        let _ = updates_rx.try_recv();
4068        let _ = view_rx.borrow_and_update();
4069
4070        let mut view = view_at_ordinal(7);
4071        view.connected = false;
4072        view.error = Some(ViewError::Unreachable("relay is unreachable".into()));
4073        publish_view("session-1", view, &view_tx, &updates_tx);
4074
4075        assert!(view_rx.has_changed().expect("watch stays open"));
4076        assert!(updates_rx.try_recv().is_ok());
4077    }
4078
4079    #[test]
4080    fn command_ids_are_namespaced_and_unique() {
4081        let first = new_command_id("prompt").unwrap();
4082        let second = new_command_id("prompt").unwrap();
4083        assert!(first.starts_with("prompt-"));
4084        assert_ne!(first, second);
4085    }
4086
4087    #[test]
4088    fn leased_actor_defers_replacement_and_uses_latest_queued_target() {
4089        let original = target("relay-v1");
4090        let intermediate = target("relay-v2");
4091        let latest = target("relay-v3");
4092        let mut lifecycle = ActorLifecycle::default();
4093        lifecycle.activate_lease(7);
4094
4095        assert_eq!(
4096            reconcile_action(Some(&original), Some(&intermediate)),
4097            ReconcileAction::Retire
4098        );
4099        lifecycle.set_retirement_requested(true);
4100        assert!(!lifecycle.accepts_new_work());
4101        assert!(!lifecycle.should_stop());
4102
4103        assert_eq!(
4104            reconcile_action(Some(&original), Some(&latest)),
4105            ReconcileAction::Retire
4106        );
4107        assert!(lifecycle.return_lease(7));
4108        assert!(lifecycle.should_stop());
4109
4110        assert_eq!(
4111            reconcile_action(None, Some(&latest)),
4112            ReconcileAction::Spawn
4113        );
4114    }
4115
4116    #[test]
4117    fn leased_actor_defers_removal_until_its_connection_returns() {
4118        let original = target("relay-v1");
4119        let mut lifecycle = ActorLifecycle::default();
4120        lifecycle.activate_lease(11);
4121
4122        assert_eq!(
4123            reconcile_action(Some(&original), None),
4124            ReconcileAction::Retire
4125        );
4126        lifecycle.set_retirement_requested(true);
4127        assert!(!lifecycle.should_stop());
4128        assert!(!lifecycle.return_lease(10));
4129        assert!(!lifecycle.should_stop());
4130        assert!(lifecycle.return_lease(11));
4131        assert!(lifecycle.should_stop());
4132        assert_eq!(reconcile_action(None, None), ReconcileAction::Idle);
4133    }
4134
4135    #[test]
4136    fn queued_change_back_to_current_target_cancels_retirement() {
4137        let original = target("relay-v1");
4138        let replacement = target("relay-v2");
4139        let mut lifecycle = ActorLifecycle::default();
4140        lifecycle.activate_lease(3);
4141
4142        assert_eq!(
4143            reconcile_action(Some(&original), Some(&replacement)),
4144            ReconcileAction::Retire
4145        );
4146        lifecycle.set_retirement_requested(true);
4147        assert_eq!(
4148            reconcile_action(Some(&original), Some(&original)),
4149            ReconcileAction::Keep
4150        );
4151        lifecycle.set_retirement_requested(false);
4152
4153        assert!(lifecycle.return_lease(3));
4154        assert!(!lifecycle.should_stop());
4155        assert!(lifecycle.accepts_new_work());
4156    }
4157
4158    #[tokio::test]
4159    async fn stopped_actor_is_replaced_without_late_completion_removing_replacement() {
4160        let desired = target("sh");
4161        let desired_targets = target_map(std::slice::from_ref(&desired));
4162        let mut actors = BTreeMap::new();
4163        let mut tasks = tokio::task::JoinSet::new();
4164        let (commands, commands_rx) = mpsc::channel(1);
4165        drop(commands_rx);
4166        let (releases, _releases_rx) = mpsc::unbounded_channel();
4167        let (retirement, _retirement_rx) = watch::channel(false);
4168        let (_view_tx, view) = watch::channel(ManagedSessionView::default());
4169        let old_abort = tasks.spawn(async { "session-1".to_owned() });
4170        let old_task_id = old_abort.id();
4171        actors.insert(
4172            "session-1".to_owned(),
4173            ActorRegistration {
4174                target: desired.clone(),
4175                commands,
4176                releases,
4177                retirement,
4178                view,
4179                abort: old_abort,
4180            },
4181        );
4182        let (updates, _updates_rx) = coalesced_update_channel();
4183
4184        reconcile_actors(&desired_targets, &mut actors, &mut tasks, &updates);
4185
4186        let replacement_task_id = actors["session-1"].abort.id();
4187        assert_ne!(replacement_task_id, old_task_id);
4188        assert!(!actors["session-1"].commands.is_closed());
4189        assert_eq!(remove_actor_task(&mut actors, old_task_id), None);
4190        assert_eq!(actors["session-1"].abort.id(), replacement_task_id);
4191        tasks.abort_all();
4192    }
4193
4194    const UNREACHABLE_VIEW_TEST_CHILD: &str = "MJ_TEST_UNREACHABLE_RELAY_CHILD";
4195
4196    #[tokio::test(start_paused = true)]
4197    async fn unreachable_relay_publishes_error_view() {
4198        // MJ_DATA_DIR is process-global, so run the database-backed half in
4199        // an exact child test instead of racing unrelated tests in this
4200        // process.
4201        if std::env::var_os(UNREACHABLE_VIEW_TEST_CHILD).is_none() {
4202            let directory = tempfile::tempdir().unwrap();
4203            let test_name = format!(
4204                "{}::unreachable_relay_publishes_error_view",
4205                module_path!()
4206                    .strip_prefix("mj_controller::")
4207                    .unwrap_or(module_path!())
4208            );
4209            let output = std::process::Command::new(std::env::current_exe().unwrap())
4210                .args(["--exact", &test_name, "--nocapture"])
4211                .env(UNREACHABLE_VIEW_TEST_CHILD, "1")
4212                .env("MJ_DATA_DIR", directory.path())
4213                .output()
4214                .unwrap();
4215            assert!(
4216                output.status.success(),
4217                "isolated unreachable relay test failed\nstdout:\n{}\nstderr:\n{}",
4218                String::from_utf8_lossy(&output.stdout),
4219                String::from_utf8_lossy(&output.stderr)
4220            );
4221            return;
4222        }
4223
4224        // A regression in the publish path deadlocks the actor instead of
4225        // returning an error, so convert a hang into a hard failure.
4226        std::thread::spawn(|| {
4227            std::thread::sleep(Duration::from_secs(60));
4228            eprintln!("unreachable relay error view was never published");
4229            std::process::exit(101);
4230        });
4231
4232        let (_commands_tx, commands_rx) = mpsc::channel(4);
4233        let (_releases_tx, releases_rx) = mpsc::unbounded_channel();
4234        let (_retirement_tx, retirement_rx) = watch::channel(false);
4235        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
4236        let (updates_tx, mut updates_rx) = coalesced_update_channel();
4237        tokio::spawn(run_session_actor(
4238            target("hel-relay-program-that-does-not-exist"),
4239            commands_rx,
4240            releases_rx,
4241            retirement_rx,
4242            view_tx,
4243            updates_tx,
4244        ));
4245
4246        loop {
4247            view_rx.changed().await.unwrap();
4248            let view = view_rx.borrow_and_update().clone();
4249            if !view.connected {
4250                let error = view
4251                    .error
4252                    .expect("unreachable view carries the connect error");
4253                assert!(
4254                    error.detail().contains("session relay proxy"),
4255                    "unexpected error: {error:?}"
4256                );
4257                break;
4258            }
4259        }
4260        let update = updates_rx
4261            .recv()
4262            .await
4263            .expect("dashboard feed received the error view");
4264        assert_eq!(update.session_id, "session-1");
4265        assert!(!update.view.connected);
4266    }
4267
4268    const UNREADABLE_PROJECTION_TEST_CHILD: &str = "MJ_TEST_UNREADABLE_PROJECTION_CHILD";
4269
4270    #[tokio::test]
4271    async fn connecting_to_an_absent_worker_never_reads_the_projection() {
4272        // MJ_DATA_DIR is process-global, so run the database-backed half in
4273        // an exact child test instead of racing unrelated tests in this
4274        // process.
4275        if std::env::var_os(UNREADABLE_PROJECTION_TEST_CHILD).is_none() {
4276            let directory = tempfile::tempdir().unwrap();
4277            // A directory where the database file belongs makes every
4278            // projection read fail, so a read that happens at all shows up in
4279            // the reported error.
4280            std::fs::create_dir(directory.path().join("mj.sqlite3")).unwrap();
4281            let output = std::process::Command::new(std::env::current_exe().unwrap())
4282                .args([
4283                    "--exact",
4284                    &format!(
4285                        "{}::connecting_to_an_absent_worker_never_reads_the_projection",
4286                        module_path!()
4287                            .strip_prefix("mj_controller::")
4288                            .unwrap_or(module_path!())
4289                    ),
4290                    "--nocapture",
4291                ])
4292                .env(UNREADABLE_PROJECTION_TEST_CHILD, "1")
4293                .env("MJ_DATA_DIR", directory.path())
4294                .output()
4295                .unwrap();
4296            assert!(
4297                output.status.success(),
4298                "isolated projection ordering test failed\nstdout:\n{}\nstderr:\n{}",
4299                String::from_utf8_lossy(&output.stdout),
4300                String::from_utf8_lossy(&output.stderr)
4301            );
4302            return;
4303        }
4304
4305        assert!(
4306            hel::hel_database::load_materialized_session("session-1").is_err(),
4307            "this store must fail every projection read for the test to mean anything"
4308        );
4309        let connected =
4310            StandaloneSession::connect(&target("hel-relay-program-that-does-not-exist")).await;
4311        let error = match connected {
4312            Ok(_) => panic!("a relay program that does not exist cannot connect"),
4313            Err(error) => error,
4314        };
4315        let detail = format!("{error:#}");
4316        assert!(
4317            detail.contains("session relay proxy"),
4318            "unexpected error: {detail}"
4319        );
4320        assert!(
4321            !detail.contains("Mjolnir database"),
4322            "connect read the projection before it reached the relay: {detail}"
4323        );
4324    }
4325
4326    const LEASED_RELAY_ROOT: &str = "MJ_TEST_LEASED_RELAY_ROOT";
4327    #[cfg(unix)]
4328    const AUTO_RESTART_TEST_CHILD: &str = "MJ_TEST_AUTO_RESTART_CHILD";
4329    #[cfg(unix)]
4330    const AUTO_RESTART_MARKER: &str = "MJ_TEST_AUTO_RESTART_MARKER";
4331    #[cfg(unix)]
4332    const DEFERRED_SUBMIT_TEST_CHILD: &str = "MJ_TEST_DEFERRED_SUBMIT_CHILD";
4333    #[cfg(unix)]
4334    const RETIRED_SUBMIT_TEST_CHILD: &str = "MJ_TEST_RETIRED_SUBMIT_CHILD";
4335    #[cfg(unix)]
4336    const RETURNED_LEASE_VIEW_TEST_CHILD: &str = "MJ_TEST_RETURNED_LEASE_VIEW_CHILD";
4337    #[cfg(unix)]
4338    const EXPLICIT_MEMORY_SYNC_TEST_CHILD: &str = "MJ_TEST_EXPLICIT_MEMORY_SYNC_CHILD";
4339    #[cfg(unix)]
4340    const SUBMIT_WITHOUT_SYNC_TEST_CHILD: &str = "MJ_TEST_SUBMIT_WITHOUT_SYNC_CHILD";
4341    #[cfg(unix)]
4342    const MANAGER_SHUTDOWN_TEST_CHILD: &str = "MJ_TEST_MANAGER_SHUTDOWN_CHILD";
4343    const LEASED_RELAY_SESSION: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
4344
4345    /// Relay server half of the leased-submission tests. It does nothing unless
4346    /// a parent test points it at a relay journal root.
4347    #[test]
4348    fn leased_relay_child_serves_stdio() {
4349        let Some(root) = std::env::var_os(LEASED_RELAY_ROOT) else {
4350            return;
4351        };
4352        // With `--nocapture` libtest writes `test <name> ... ` without a
4353        // trailing newline before the body runs. End that line first so it
4354        // cannot glue itself onto the first protocol frame.
4355        println!();
4356        let mut relay = hel::hel_worker::DurableRelay::open(
4357            std::path::Path::new(&root),
4358            LEASED_RELAY_SESSION,
4359            "1.0.0",
4360        )
4361        .expect("open the test relay journal");
4362        if let Some(marker) = std::env::var_os("MJ_TEST_BLOCKED_REVIEWER") {
4363            let mut input = std::io::stdin().lock();
4364            let mut output = std::io::stdout().lock();
4365            while let Some(request) = hel::hel_worker::read_relay_frame(&mut input).unwrap() {
4366                let response = if let hel::hel_worker::RelayRequest::Reviewer { role, .. } =
4367                    &request.request
4368                {
4369                    if role.as_deref() == Some("slow") {
4370                        std::fs::write(&marker, b"started").unwrap();
4371                        std::io::copy(&mut input, &mut std::io::sink()).unwrap();
4372                        std::fs::write(&marker, b"disconnected").unwrap();
4373                        return;
4374                    }
4375                    hel::hel_worker::RelayResponseEnvelope {
4376                        request_id: request.request_id,
4377                        protocol_version: request.protocol_version,
4378                        body: hel::hel_worker::RelayResponseBody::Ok {
4379                            payload: hel::hel_worker::RelayResponsePayload::ReviewerPaused,
4380                        },
4381                    }
4382                } else {
4383                    relay.handle(request)
4384                };
4385                hel::hel_worker::write_relay_frame(&mut output, &response).unwrap();
4386            }
4387            return;
4388        }
4389        hel::hel_worker::serve_relay_json_lines(
4390            &mut std::io::stdin().lock(),
4391            &mut std::io::stdout().lock(),
4392            &mut relay,
4393        )
4394        .expect("serve relay frames until the controller disconnects");
4395    }
4396
4397    #[cfg(unix)]
4398    fn exact_test_name(test: &str) -> String {
4399        format!(
4400            "{}::{test}",
4401            module_path!()
4402                .strip_prefix("mj_controller::")
4403                .unwrap_or(module_path!())
4404        )
4405    }
4406
4407    /// MJ_DATA_DIR is process-global, so every test that reaches the
4408    /// controller database runs in an exact child with its own data directory.
4409    #[cfg(unix)]
4410    fn run_in_isolated_child(marker: &str, test: &str) {
4411        let directory = tempfile::tempdir().unwrap();
4412        let output = std::process::Command::new(std::env::current_exe().unwrap())
4413            .args(["--exact", &exact_test_name(test), "--nocapture"])
4414            .env(marker, "1")
4415            .env("MJ_DATA_DIR", directory.path())
4416            .output()
4417            .unwrap();
4418        assert!(
4419            output.status.success(),
4420            "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
4421            String::from_utf8_lossy(&output.stdout),
4422            String::from_utf8_lossy(&output.stderr)
4423        );
4424    }
4425
4426    #[cfg(unix)]
4427    #[tokio::test]
4428    async fn session_manager_shutdown_joins_a_live_relay_actor() {
4429        if std::env::var_os(MANAGER_SHUTDOWN_TEST_CHILD).is_none() {
4430            run_in_isolated_child(
4431                MANAGER_SHUTDOWN_TEST_CHILD,
4432                "session_manager_shutdown_joins_a_live_relay_actor",
4433            );
4434            return;
4435        }
4436        // Alone in this child process, so it installs the one writer.
4437        let _writer = hel::hel_database::install_isolated_test_writer();
4438        register_leased_relay_session();
4439        let relay_root = tempfile::tempdir().unwrap();
4440        let SessionManagerChannels {
4441            targets,
4442            control,
4443            updates: _updates,
4444            shutdown,
4445        } = spawn_session_manager().expect("spawn the session manager");
4446        targets.send_replace(vec![leased_relay_target(relay_root.path())]);
4447        let session = control
4448            .wait_for_session(LEASED_RELAY_SESSION, Duration::from_secs(2))
4449            .await
4450            .expect("manager registered the relay actor");
4451        session
4452            .sync_now()
4453            .await
4454            .expect("relay actor established a live connection");
4455        assert!(session.view().connected);
4456
4457        tokio::time::timeout(Duration::from_secs(2), shutdown.shutdown())
4458            .await
4459            .expect("manager shutdown stayed within its deadline")
4460            .expect("manager shutdown task completed cleanly");
4461    }
4462
4463    #[cfg(unix)]
4464    #[tokio::test]
4465    async fn a_blocked_reviewer_keeps_primary_responsive_and_disconnects_on_cancellation() {
4466        const CHILD: &str = "MJ_TEST_REVIEWER_CANCELLATION_CHILD";
4467        if std::env::var_os(CHILD).is_none() {
4468            run_in_isolated_child(
4469                CHILD,
4470                "a_blocked_reviewer_keeps_primary_responsive_and_disconnects_on_cancellation",
4471            );
4472            return;
4473        }
4474        let _writer = hel::hel_database::install_isolated_test_writer();
4475        register_leased_relay_session();
4476        let directory = tempfile::tempdir().unwrap();
4477        let marker = directory.path().join("reviewer-status");
4478        let mut target = leased_relay_target(directory.path());
4479        target.spec.env.insert(
4480            "MJ_TEST_BLOCKED_REVIEWER".into(),
4481            marker.to_string_lossy().into_owned(),
4482        );
4483        let manager = spawn_session_manager().unwrap();
4484        manager.targets.send_replace(vec![target]);
4485        let session = manager
4486            .control
4487            .wait_for_session(LEASED_RELAY_SESSION, Duration::from_secs(5))
4488            .await
4489            .unwrap();
4490        session.sync_now().await.unwrap();
4491        let slow = session.clone();
4492        let blocked = tokio::spawn(async move {
4493            slow.reviewer_as(Some("slow".into()), ReviewerAction::Pause)
4494                .await
4495        });
4496        tokio::time::timeout(Duration::from_secs(5), async {
4497            while !marker.exists() {
4498                tokio::time::sleep(Duration::from_millis(10)).await;
4499            }
4500        })
4501        .await
4502        .expect("slow reviewer started");
4503        tokio::time::timeout(Duration::from_secs(2), session.sync_now())
4504            .await
4505            .expect("primary sync must not wait for reviewer")
4506            .unwrap();
4507        tokio::time::timeout(
4508            Duration::from_secs(2),
4509            session.reviewer_as(Some("other".into()), ReviewerAction::Pause),
4510        )
4511        .await
4512        .expect("another role must remain responsive")
4513        .unwrap();
4514        blocked.abort();
4515        assert!(blocked.await.unwrap_err().is_cancelled());
4516        tokio::time::timeout(Duration::from_secs(5), async {
4517            while std::fs::read(&marker).unwrap() != b"disconnected" {
4518                tokio::time::sleep(Duration::from_millis(10)).await;
4519            }
4520        })
4521        .await
4522        .expect("cancelling the caller must disconnect its in-flight reviewer proxy");
4523        tokio::time::timeout(Duration::from_secs(2), manager.shutdown.shutdown())
4524            .await
4525            .unwrap()
4526            .unwrap();
4527    }
4528
4529    #[cfg(unix)]
4530    #[tokio::test]
4531    async fn relay_attach_does_not_probe_or_install_project_memory() {
4532        if std::env::var_os(EXPLICIT_MEMORY_SYNC_TEST_CHILD).is_none() {
4533            run_in_isolated_child(
4534                EXPLICIT_MEMORY_SYNC_TEST_CHILD,
4535                "relay_attach_does_not_probe_or_install_project_memory",
4536            );
4537            return;
4538        }
4539        // Alone in this child process, so it installs the one writer.
4540        let _writer = hel::hel_database::install_isolated_test_writer();
4541        register_leased_relay_session();
4542        let relay_root = tempfile::tempdir().unwrap();
4543        let canonical = tempfile::tempdir().unwrap();
4544        let mut target = leased_relay_target(relay_root.path());
4545        target.project_memory = Some(ProjectMemorySyncTarget {
4546            canonical_root: canonical.path().to_path_buf(),
4547        });
4548
4549        let mut connection = StandaloneSession::connect(&target)
4550            .await
4551            .expect("relay attach must not depend on its memory endpoint");
4552        assert!(
4553            connection.project_memory.is_some(),
4554            "attach must leave memory pending for an explicit checkpoint sync"
4555        );
4556
4557        connection
4558            .sync_project_memory()
4559            .await
4560            .expect("an explicit sync may detect a legacy memory endpoint");
4561        assert!(
4562            connection.project_memory.is_none(),
4563            "the explicit sync reached the relay and disabled its unavailable endpoint"
4564        );
4565    }
4566
4567    /// Catching the local projection up to an accepted command is the
4568    /// expensive half of a submit, and a caller waiting to hear that the relay
4569    /// took the command should not wait for it. The two are separate calls, so
4570    /// the cheap one can answer first.
4571    #[cfg(unix)]
4572    #[tokio::test]
4573    async fn submitting_does_not_catch_the_projection_up_until_asked() {
4574        if std::env::var_os(SUBMIT_WITHOUT_SYNC_TEST_CHILD).is_none() {
4575            run_in_isolated_child(
4576                SUBMIT_WITHOUT_SYNC_TEST_CHILD,
4577                "submitting_does_not_catch_the_projection_up_until_asked",
4578            );
4579            return;
4580        }
4581        // Alone in this child process, so it installs the one writer.
4582        let _writer = hel::hel_database::install_isolated_test_writer();
4583        register_leased_relay_session();
4584        let relay_root = tempfile::tempdir().unwrap();
4585        let mut connection = StandaloneSession::connect(&leased_relay_target(relay_root.path()))
4586            .await
4587            .expect("connect to the live test relay");
4588        let before = connection.materialized.applied_event_ordinal;
4589
4590        let ordinal = connection
4591            .submit_accepted(
4592                new_command_id("prompt").unwrap(),
4593                RelayCommand::Prompt {
4594                    prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
4595                },
4596            )
4597            .await
4598            .expect("the relay accepted the command");
4599        assert!(ordinal > before, "the relay reported where it accepted it");
4600        assert_eq!(
4601            connection.materialized.applied_event_ordinal, before,
4602            "the caller was answered without paying for the catch-up"
4603        );
4604
4605        connection.sync().await.expect("catch the projection up");
4606        assert!(
4607            connection.materialized.applied_event_ordinal > before,
4608            "the catch-up is what advances the projection"
4609        );
4610    }
4611
4612    #[cfg(unix)]
4613    #[tokio::test]
4614    async fn unresponsive_live_relay_worker_is_restarted_and_reconnected() {
4615        if std::env::var_os(AUTO_RESTART_TEST_CHILD).is_none() {
4616            run_in_isolated_child(
4617                AUTO_RESTART_TEST_CHILD,
4618                "unresponsive_live_relay_worker_is_restarted_and_reconnected",
4619            );
4620            return;
4621        }
4622        // Alone in this child process, so it installs the one writer.
4623        let _writer = hel::hel_database::install_isolated_test_writer();
4624        fail_if_the_actor_stalls("unresponsive live relay worker was never restarted");
4625        register_leased_relay_session();
4626        let relay_root = tempfile::tempdir().unwrap();
4627        let restarted = relay_root.path().join("worker-restarted");
4628        let script = format!(
4629            "if [ ! -f \"${AUTO_RESTART_MARKER}\" ]; then IFS= read -r _; exit 0; fi; \
4630             \"$0\" --exact {} --nocapture | grep --line-buffered '^{{'",
4631            exact_test_name("leased_relay_child_serves_stdio")
4632        );
4633        let mut spec = CommandSpec::new(
4634            "sh",
4635            [
4636                "-c".to_owned(),
4637                script,
4638                std::env::current_exe()
4639                    .unwrap()
4640                    .to_string_lossy()
4641                    .into_owned(),
4642            ],
4643        )
4644        .purpose("test restartable relay");
4645        spec.env.insert(
4646            LEASED_RELAY_ROOT.to_owned(),
4647            relay_root.path().to_string_lossy().into_owned(),
4648        );
4649        spec.env.insert(
4650            AUTO_RESTART_MARKER.to_owned(),
4651            restarted.to_string_lossy().into_owned(),
4652        );
4653        let worker_recovery = WorkerRecoveryPlan {
4654            target: None,
4655            workspace: None,
4656            liveness_probe: CommandSpec::new("printf", ["alive\n"])
4657                .purpose("probe test relay worker"),
4658            binary_refresh: None,
4659            launch_refresh: None,
4660            restart: CommandPlan {
4661                description: "restart test relay worker".into(),
4662                commands: vec![
4663                    CommandSpec::new("touch", [restarted.to_string_lossy().into_owned()])
4664                        .purpose("restart test relay worker"),
4665                ],
4666            },
4667        };
4668        let target = RelaySessionTarget {
4669            session_id: LEASED_RELAY_SESSION.to_owned(),
4670            spec,
4671            worker_recovery: Some(worker_recovery),
4672            project_memory: None,
4673        };
4674        let (_commands_tx, commands_rx) = mpsc::channel(4);
4675        let (_releases_tx, releases_rx) = mpsc::unbounded_channel();
4676        let (_retirement_tx, retirement_rx) = watch::channel(false);
4677        let (view_tx, mut view_rx) = watch::channel(ManagedSessionView::default());
4678        let (updates_tx, _updates_rx) = coalesced_update_channel();
4679        tokio::spawn(run_session_actor(
4680            target,
4681            commands_rx,
4682            releases_rx,
4683            retirement_rx,
4684            view_tx,
4685            updates_tx,
4686        ));
4687
4688        tokio::time::timeout(Duration::from_secs(20), async {
4689            loop {
4690                view_rx.changed().await.unwrap();
4691                let view = view_rx.borrow_and_update().clone();
4692                if view.connected {
4693                    assert!(restarted.exists(), "the restart plan did not run");
4694                    assert!(view.error.is_none());
4695                    return;
4696                }
4697            }
4698        })
4699        .await
4700        .unwrap_or_else(|_| panic!("relay stayed disconnected: {:?}", view_rx.borrow().error));
4701    }
4702
4703    /// A deferred submission that is never answered would hang the suite
4704    /// instead of failing it, so turn a stall into a hard error.
4705    #[cfg(unix)]
4706    fn fail_if_the_actor_stalls(reason: &'static str) {
4707        std::thread::spawn(move || {
4708            std::thread::sleep(Duration::from_secs(60));
4709            eprintln!("{reason}");
4710            std::process::exit(101);
4711        });
4712    }
4713
4714    /// A relay target served by this test binary over stdio.
4715    #[cfg(unix)]
4716    fn leased_relay_target(relay_root: &std::path::Path) -> RelaySessionTarget {
4717        // `RelayClient` parses every stdout line as JSON, so libtest's own
4718        // progress lines are dropped before they reach the protocol reader.
4719        let script = format!(
4720            "\"$0\" --exact {} --nocapture | grep --line-buffered '^{{'",
4721            exact_test_name("leased_relay_child_serves_stdio")
4722        );
4723        let mut spec = CommandSpec::new(
4724            "sh",
4725            [
4726                "-c".to_owned(),
4727                script,
4728                std::env::current_exe()
4729                    .unwrap()
4730                    .to_string_lossy()
4731                    .into_owned(),
4732            ],
4733        )
4734        .purpose("test leased relay");
4735        spec.env.insert(
4736            LEASED_RELAY_ROOT.to_owned(),
4737            relay_root.to_string_lossy().into_owned(),
4738        );
4739        RelaySessionTarget {
4740            session_id: LEASED_RELAY_SESSION.to_owned(),
4741            spec,
4742            worker_recovery: None,
4743            project_memory: None,
4744        }
4745    }
4746
4747    /// Register the session the projection writes to. `apply_projection_event`
4748    /// rejects events for sessions the controller database does not know.
4749    #[cfg(unix)]
4750    fn register_leased_relay_session() {
4751        hel::hel_database::save_session(&hel::hel_state::SessionRecord {
4752            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
4753            archived: false,
4754            container_cpus: None,
4755            container_memory: None,
4756            id: LEASED_RELAY_SESSION.into(),
4757            title: "leased relay".into(),
4758            harness_kind: hel::hel_config::HarnessKind::Codex,
4759            last_profile: "codex".into(),
4760            bundle_id: "project".into(),
4761            project_directory: None,
4762            managed_worktree: None,
4763            target_template_id: "podman".into(),
4764            resource_allocation: None,
4765            additional_mounts: Vec::new(),
4766            state: hel::hel_state::SessionState::Running,
4767            target: None,
4768            native_session_id: None,
4769            acp_session_title: None,
4770            session_title_override: None,
4771            created_at: "2026-08-12T00:00:00Z".into(),
4772            updated_at: "2026-08-12T00:00:00Z".into(),
4773            viewed_through_event_ordinal: 0,
4774            draft_input: String::new(),
4775            last_error: None,
4776            last_checkpoint_error: None,
4777            checkpoint: None,
4778        })
4779        .expect("register the test session");
4780    }
4781
4782    #[cfg(unix)]
4783    struct LeasedActor {
4784        commands: mpsc::Sender<ActorCommand>,
4785        releases: mpsc::UnboundedSender<ReturnedConnection>,
4786        retirement: watch::Sender<bool>,
4787        _views: watch::Receiver<ManagedSessionView>,
4788        _updates: SessionManagerUpdates,
4789        _relay_root: tempfile::TempDir,
4790    }
4791
4792    /// Start an actor against a live relay and take its connection under lease.
4793    #[cfg(unix)]
4794    async fn lease_a_live_actor() -> (LeasedActor, u64, StandaloneSession) {
4795        register_leased_relay_session();
4796        let relay_root = tempfile::tempdir().unwrap();
4797        let (commands_tx, commands_rx) = mpsc::channel(4);
4798        let (releases_tx, releases_rx) = mpsc::unbounded_channel();
4799        let (retirement_tx, retirement_rx) = watch::channel(false);
4800        let (view_tx, view_rx) = watch::channel(ManagedSessionView::default());
4801        let (updates_tx, updates_rx) = coalesced_update_channel();
4802        tokio::spawn(run_session_actor(
4803            leased_relay_target(relay_root.path()),
4804            commands_rx,
4805            releases_rx,
4806            retirement_rx,
4807            view_tx,
4808            updates_tx,
4809        ));
4810
4811        let (reply, response) = oneshot::channel();
4812        commands_tx
4813            .send(ActorCommand::Lease { reply })
4814            .await
4815            .unwrap();
4816        let (lease_id, connection) = response
4817            .await
4818            .expect("actor answered the lease request")
4819            .expect("actor leased its relay connection");
4820        (
4821            LeasedActor {
4822                commands: commands_tx,
4823                releases: releases_tx,
4824                retirement: retirement_tx,
4825                _views: view_rx,
4826                _updates: updates_rx,
4827                _relay_root: relay_root,
4828            },
4829            lease_id,
4830            connection,
4831        )
4832    }
4833
4834    #[cfg(unix)]
4835    async fn submit_a_deferred_prompt(
4836        actor: &LeasedActor,
4837    ) -> oneshot::Receiver<std::result::Result<u64, String>> {
4838        let (reply, mut response) = oneshot::channel();
4839        actor
4840            .commands
4841            .send(ActorCommand::Submit {
4842                command_id: new_command_id("prompt").unwrap(),
4843                command: RelayCommand::Prompt {
4844                    prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
4845                },
4846                admission: None,
4847                reply,
4848            })
4849            .await
4850            .unwrap();
4851        assert!(
4852            tokio::time::timeout(Duration::from_millis(300), &mut response)
4853                .await
4854                .is_err(),
4855            "a leased actor must hold the prompt instead of answering it"
4856        );
4857        response
4858    }
4859
4860    #[cfg(unix)]
4861    #[tokio::test]
4862    async fn prompt_submitted_during_lease_is_delivered_after_release() {
4863        if std::env::var_os(DEFERRED_SUBMIT_TEST_CHILD).is_none() {
4864            run_in_isolated_child(
4865                DEFERRED_SUBMIT_TEST_CHILD,
4866                "prompt_submitted_during_lease_is_delivered_after_release",
4867            );
4868            return;
4869        }
4870        // Alone in this child process, so it installs the one writer.
4871        let _writer = hel::hel_database::install_isolated_test_writer();
4872        fail_if_the_actor_stalls("prompt deferred during a lease was never delivered");
4873
4874        let (actor, lease_id, connection) = lease_a_live_actor().await;
4875        let response = submit_a_deferred_prompt(&actor).await;
4876
4877        actor
4878            .releases
4879            .send(ReturnedConnection {
4880                lease_id,
4881                connection: Some(connection),
4882            })
4883            .unwrap();
4884
4885        let ordinal = response
4886            .await
4887            .expect("actor answered the deferred prompt")
4888            .expect("deferred prompt reached the relay");
4889        assert!(
4890            ordinal > 0,
4891            "relay accepted the prompt at ordinal {ordinal}"
4892        );
4893    }
4894
4895    #[cfg(unix)]
4896    #[tokio::test]
4897    async fn returned_lease_publishes_what_it_learned_while_it_held_the_connection() {
4898        if std::env::var_os(RETURNED_LEASE_VIEW_TEST_CHILD).is_none() {
4899            run_in_isolated_child(
4900                RETURNED_LEASE_VIEW_TEST_CHILD,
4901                "returned_lease_publishes_what_it_learned_while_it_held_the_connection",
4902            );
4903            return;
4904        }
4905        // Alone in this child process, so it installs the one writer.
4906        let _writer = hel::hel_database::install_isolated_test_writer();
4907        fail_if_the_actor_stalls("a returned lease never republished its session");
4908
4909        let (actor, lease_id, mut connection) = lease_a_live_actor().await;
4910        let mut views = actor._views.clone();
4911        // The lease applies these events itself, so the actor's own next sync
4912        // has nothing left to catch up on.
4913        let ordinal = connection
4914            .submit(
4915                new_command_id("prompt").unwrap(),
4916                RelayCommand::Prompt {
4917                    prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
4918                },
4919            )
4920            .await
4921            .unwrap();
4922        assert!(views.borrow_and_update().snapshot.is_none());
4923
4924        actor
4925            .releases
4926            .send(ReturnedConnection {
4927                lease_id,
4928                connection: Some(connection),
4929            })
4930            .unwrap();
4931
4932        views.changed().await.unwrap();
4933        let snapshot = views
4934            .borrow_and_update()
4935            .snapshot
4936            .clone()
4937            .expect("the returned connection republished its session");
4938        assert!(
4939            snapshot.materialized.applied_event_ordinal >= ordinal,
4940            "published frontier {} is behind the leased submission at {ordinal}",
4941            snapshot.materialized.applied_event_ordinal
4942        );
4943    }
4944
4945    #[cfg(unix)]
4946    #[tokio::test]
4947    async fn retirement_rejects_prompts_deferred_during_lease() {
4948        if std::env::var_os(RETIRED_SUBMIT_TEST_CHILD).is_none() {
4949            run_in_isolated_child(
4950                RETIRED_SUBMIT_TEST_CHILD,
4951                "retirement_rejects_prompts_deferred_during_lease",
4952            );
4953            return;
4954        }
4955        // Alone in this child process, so it installs the one writer.
4956        let _writer = hel::hel_database::install_isolated_test_writer();
4957        fail_if_the_actor_stalls("prompt deferred during a lease was never answered");
4958
4959        let (actor, lease_id, connection) = lease_a_live_actor().await;
4960        let response = submit_a_deferred_prompt(&actor).await;
4961
4962        actor.retirement.send(true).unwrap();
4963        actor
4964            .releases
4965            .send(ReturnedConnection {
4966                lease_id,
4967                connection: Some(connection),
4968            })
4969            .unwrap();
4970
4971        let error = response
4972            .await
4973            .expect("actor answered the deferred prompt")
4974            .expect_err("a retiring actor must not deliver the prompt");
4975        assert!(
4976            error.contains("session target is changing"),
4977            "unexpected rejection: {error}"
4978        );
4979    }
4980
4981    #[test]
4982    fn projection_integrity_failure_is_detected_only_for_integrity_errors() {
4983        let integrity = anyhow::Error::from(ProjectionIntegrityError(
4984            "transcript item \"tool:call-1\" changed immutable identity fields".into(),
4985        ))
4986        .context("apply projection event");
4987        assert!(projection_integrity_failure(&integrity));
4988
4989        let concurrent = anyhow::Error::from(ProjectionAdvancedError { event_ordinal: 7 });
4990        assert!(!projection_integrity_failure(&concurrent));
4991
4992        let unreachable = anyhow::anyhow!("connection refused").context("connect relay proxy");
4993        assert!(!projection_integrity_failure(&unreachable));
4994    }
4995
4996    #[test]
4997    fn dashboard_updates_keep_only_the_latest_view_per_session() {
4998        let (sender, mut receiver) = coalesced_update_channel();
4999        for revision in 0..1_000 {
5000            sender.send(SessionManagerUpdate {
5001                session_id: "session-1".into(),
5002                view: ManagedSessionView {
5003                    error: Some(ViewError::Unreachable(format!("revision-{revision}"))),
5004                    ..ManagedSessionView::default()
5005                },
5006            });
5007        }
5008        sender.send(SessionManagerUpdate {
5009            session_id: "session-2".into(),
5010            view: ManagedSessionView {
5011                error: Some(ViewError::Unreachable("other".into())),
5012                ..ManagedSessionView::default()
5013            },
5014        });
5015
5016        assert_eq!(
5017            sender
5018                .pending
5019                .lock()
5020                .expect("session update coalescer poisoned")
5021                .len(),
5022            2
5023        );
5024        let updates = [receiver.try_recv().unwrap(), receiver.try_recv().unwrap()]
5025            .into_iter()
5026            .map(|update| (update.session_id, update.view.error.unwrap()))
5027            .collect::<BTreeMap<_, _>>();
5028        assert_eq!(updates["session-1"].detail(), "revision-999");
5029        assert_eq!(updates["session-2"].detail(), "other");
5030        assert!(receiver.try_recv().is_err());
5031    }
5032
5033    #[tokio::test]
5034    async fn remote_session_manager_fans_out_views_and_forwards_commands() {
5035        let mut remote = spawn_remote_session_manager().unwrap();
5036        remote.targets.send_replace(vec![target("unused")]);
5037        remote
5038            .publisher
5039            .publish("session-1".into(), view_at_ordinal(7))
5040            .await
5041            .unwrap();
5042
5043        let session = remote
5044            .control
5045            .wait_for_session("session-1", Duration::from_secs(1))
5046            .await
5047            .unwrap();
5048        assert_eq!(
5049            session
5050                .view()
5051                .snapshot
5052                .as_ref()
5053                .unwrap()
5054                .materialized
5055                .applied_event_ordinal,
5056            7
5057        );
5058
5059        let submitted = session
5060            .enqueue_submit("prompt-1".into(), RelayCommand::Cancel)
5061            .await
5062            .unwrap();
5063        let request = remote.requests.recv().await.unwrap();
5064        match request {
5065            RemoteSessionRequest::Submit {
5066                session_id,
5067                command_id,
5068                command: RelayCommand::Cancel,
5069                admission: None,
5070                reply,
5071            } => {
5072                assert_eq!(session_id, "session-1");
5073                assert_eq!(command_id, "prompt-1");
5074                reply.send(Ok(8)).unwrap();
5075            }
5076            _ => panic!("unexpected remote session request"),
5077        }
5078        assert_eq!(submitted.wait().await.unwrap(), 8);
5079        remote.shutdown.shutdown().await.unwrap();
5080    }
5081}