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