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