1use 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);
35const PROJECTION_TRANSACTION_EVENT_BUDGET: usize = 128;
39const RECONNECT_INTERVAL: Duration = Duration::from_secs(1);
40const 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
65fn 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 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 pub binary_refresh: Option<WorkerBinaryRefresh>,
98 pub launch_refresh: Option<WorkerLaunchRefreshPlan>,
101 pub restart: CommandPlan,
102}
103
104#[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#[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
142pub(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 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#[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
305pub 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#[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 RespondElicitation {
374 elicitation_id: String,
375 response: ElicitationResponse,
376 },
377 Pause,
378 CaptureDelta {
380 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
381 },
382 AdvanceBaseline {
384 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
385 },
386 AnalyzeDelta {
388 repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
389 },
390 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#[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 Delta {
427 repositories: Vec<hel::hel_worker::RepoDelta>,
428 },
429 BaselineAdvanced,
430 ChangedFunctions {
432 packet: String,
433 },
434 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 role: Option<String>,
462 action: ReviewerAction,
463 reply: oneshot::Sender<std::result::Result<ReviewerOutcome, String>>,
464 },
465}
466
467impl RemoteSessionRequest {
468 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#[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 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 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 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
540pub 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
579pub 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#[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
688pub 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 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(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 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 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 pub async fn reviewer(&self, action: ReviewerAction) -> Result<ReviewerOutcome> {
865 self.reviewer_as(None, action).await
866 }
867
868 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 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
1090struct 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 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
1366pub 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 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 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 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 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 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 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 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 connection = returned.connection;
2126 failures = 0;
2127 interval.reset();
2128 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 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
2206async 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 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 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
2303fn 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 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
2359async 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 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
2489fn view_is_unchanged(current: &ManagedSessionView, next: &ManagedSessionView) -> bool {
2497 if current.connected != next.connected || current.error != next.error {
2498 return false;
2499 }
2500 match (¤t.snapshot, &next.snapshot) {
2501 (None, None) => true,
2502 (Some(current), Some(next)) => {
2503 let (current_session, next_session) = (¤t.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 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
2542async 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 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 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 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 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 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 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 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
2829 self.client.install_prompt_context(text).await
2830 }
2831
2832 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 let (projection, credential_sync_signal) = tokio::task::spawn_blocking(
2853 move || -> Result<(MaterializedSession, Option<CredentialSyncSignal>)> {
2854 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 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#[doc(hidden)]
3031pub struct ReplacementSessionTestFixture {
3032 pub stopped: ManagedSessionHandle,
3033 pub control: SessionManagerControl,
3034 pub submitted: mpsc::UnboundedReceiver<RelayCommand>,
3035}
3036
3037#[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 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 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 #[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 if command_id == "first" {
3171 release.notified().await;
3172 }
3173 observed.lock().unwrap().push(command_id);
3174 }
3175 });
3176 }
3177
3178 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 #[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 #[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 order.dispatch(ordering_request("session-last", "only"), |_| async {});
3298 assert_eq!(order.latest.len(), 1);
3299 }
3300
3301 #[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 #[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 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 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 #[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 #[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(¤t_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 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 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 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 if std::env::var_os(UNREADABLE_PROJECTION_TEST_CHILD).is_none() {
4162 let directory = tempfile::tempdir().unwrap();
4163 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 #[test]
4234 fn leased_relay_child_serves_stdio() {
4235 let Some(root) = std::env::var_os(LEASED_RELAY_ROOT) else {
4236 return;
4237 };
4238 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 #[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 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 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 #[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 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 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 #[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 #[cfg(unix)]
4601 fn leased_relay_target(relay_root: &std::path::Path) -> RelaySessionTarget {
4602 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 #[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 #[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 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 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 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 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}