Skip to main content

mj_controller/
worker_client.rs

1//! Controller-side client for a session relay's JSON-lines proxy.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::path::Path;
5use std::process::Stdio;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, anyhow, bail};
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::process::{Child, ChildStdin, ChildStdout, Command};
14use tokio::sync::{mpsc, watch};
15
16use crate::targets::{
17    CommandSpec, SSH_RETRY_ATTEMPTS, SshAdmission, SshPermit, is_transport_rejection,
18};
19use mj_core::config::harness_authentication_marker;
20use mj_core::credentials::{
21    CredentialSnapshot, CredentialSyncAction, CredentialSyncHandle, CredentialSyncOutcome,
22    CredentialSyncResult, CredentialSyncTarget, SYNC_INTERVAL, SyncAction, SyncTrigger, enqueue,
23    profiles_with_targets, read_credential_file, reconcile, validate_credential_payload,
24    write_credential_file,
25};
26use mj_core::elicitation::ElicitationResponse;
27use mj_core::relay::{
28    MAX_FRAME_BYTES, RELAY_EVENT_GENESIS_DIGEST, RELAY_MIN_PROTOCOL_VERSION,
29    RELAY_PROTOCOL_VERSION, RelayCommand, RelayCursor, RelayErrorCode, RelayEvent,
30    RelayOperationalState, RelayProtocolError, RelayRequest, RelayRequestEnvelope,
31    RelayResponseBody, RelayResponseEnvelope, RelayResponsePayload, RelayVersionRange,
32    ReviewerRequest, validate_relay_event,
33};
34
35pub use mj_client::session::{RelayAttachment, StartedReviewer};
36use mj_core::worker_launch::ReviewerLaunchConfig;
37
38const RELAY_RPC_TIMEOUT: Duration = Duration::from_secs(15);
39const RELAY_SLOW_OPERATION_WARNING: Duration = Duration::from_secs(5);
40/// Starting a target-side proxy may page the full worker executable in and
41/// traverse a container runtime before the relay sees `hello`. That is worker
42/// startup latency, not an ordinary in-connection RPC.
43const RELAY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(300);
44/// An attachment can decompress a transport-sized page from cold journal
45/// segments. It remains bounded by the relay frame budget, but cold or loaded
46/// storage needs a filesystem deadline rather than an in-memory RPC deadline.
47const RELAY_HISTORY_TIMEOUT: Duration = Duration::from_secs(900);
48/// Advancing an acknowledgement can durably prune a large relay journal. The
49/// worker performs that maintenance before replying, so it needs a deadline
50/// sized for filesystem work rather than ordinary relay bookkeeping.
51const RELAY_ACKNOWLEDGE_TIMEOUT: Duration = Duration::from_secs(300);
52/// Capturing a review delta runs Git over every workspace repository, which is
53/// filesystem work on a possibly large tree rather than relay bookkeeping.
54const REVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300);
55/// Bifrost's semantic diff analysis has its own 600-second budget inside the
56/// worker; this leaves room for it to report a timeout as an error rather than
57/// having the call time out underneath it.
58const REVIEW_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(660);
59const RELAY_PROXY_DETACH_GRACE: Duration = Duration::from_millis(500);
60const RELAY_PROXY_REAP_POLL: Duration = Duration::from_millis(10);
61
62/// How many trailing stderr lines a failed connect reports back to its caller.
63const RELAY_PROXY_STDERR_TAIL: usize = 10;
64
65/// Forward a relay proxy's stderr to the log, one line at a time, until the
66/// child closes it. Reporting rather than dropping keeps connect failures
67/// diagnosable now that the controller no longer shares its terminal.
68///
69/// Returns the last [`RELAY_PROXY_STDERR_TAIL`] non-empty lines, so a connect
70/// that fails can put the proxy's own complaint in the error the caller sees
71/// rather than only in the log.
72async fn drain_proxy_stderr(
73    errors: tokio::process::ChildStderr,
74    purpose: String,
75    session_id: String,
76) -> VecDeque<String> {
77    let mut tail: VecDeque<String> = VecDeque::new();
78    let mut lines = BufReader::new(errors).lines();
79    loop {
80        match lines.next_line().await {
81            Ok(Some(line)) if line.trim().is_empty() => continue,
82            Ok(Some(line)) => {
83                tracing::warn!(%session_id, %purpose, %line, "relay proxy stderr");
84                if tail.len() == RELAY_PROXY_STDERR_TAIL {
85                    tail.pop_front();
86                }
87                tail.push_back(line);
88            }
89            Ok(None) => return tail,
90            Err(error) => {
91                tracing::warn!(%session_id, %purpose, %error, "read relay proxy stderr");
92                return tail;
93            }
94        }
95    }
96}
97
98/// One bounded page in a catch-up whose upper frontier was fixed before any
99/// page was applied. The relay may return newer events on later `Attach`
100/// calls; those are deliberately left for the next catch-up.
101#[derive(Debug, Clone)]
102pub struct RelayEventPage {
103    pub events: Vec<RelayEvent>,
104    pub through_ordinal: u64,
105    pub through_digest: String,
106}
107
108#[derive(Debug, Clone)]
109pub struct RelayCatchUp {
110    pub state: RelayOperationalState,
111    pub frontier: RelayCursor,
112    pub first_page: RelayEventPage,
113}
114
115#[derive(Debug)]
116pub struct RelayRejected(pub RelayProtocolError);
117
118impl std::fmt::Display for RelayRejected {
119    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        write!(
121            formatter,
122            "relay rejected request ({:?}): {}",
123            self.0.code, self.0.message
124        )
125    }
126}
127
128impl std::error::Error for RelayRejected {}
129
130impl RelayRejected {
131    pub fn is_desynchronized(&self) -> bool {
132        self.0.code == RelayErrorCode::Desynchronized
133    }
134
135    /// Whether the relay itself said the same request could succeed later.
136    /// Validation rejections say no; transient internal failures say yes.
137    pub fn is_retryable(&self) -> bool {
138        self.0.retryable
139    }
140}
141
142/// A relay transport that can no longer carry requests: the proxy exited, one
143/// of its pipes failed, or the handshake never completed.
144///
145/// Every site that can prove this attaches the marker, and recovery decisions
146/// such as worker auto-restart downcast for it. Nothing reads the message text,
147/// so rewording a diagnostic can never silently disable recovery.
148#[derive(Debug)]
149pub struct RelayTransportDead {
150    message: String,
151    handshake_failed: bool,
152}
153
154impl std::fmt::Display for RelayTransportDead {
155    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        formatter.write_str(&self.message)
157    }
158}
159
160impl std::error::Error for RelayTransportDead {}
161
162impl RelayTransportDead {
163    pub fn new(message: impl Into<String>) -> Self {
164        Self {
165            message: message.into(),
166            handshake_failed: false,
167        }
168    }
169
170    /// Mark an I/O failure on the relay's pipes. The marker reports exactly
171    /// what the I/O error reported, so it adds a type without adding text.
172    fn from_io(error: std::io::Error, kind: ExchangeKind) -> Self {
173        Self::during_exchange(error.to_string(), kind)
174    }
175
176    fn during_exchange(message: impl Into<String>, kind: ExchangeKind) -> Self {
177        Self {
178            message: message.into(),
179            handshake_failed: kind == ExchangeKind::Handshake,
180        }
181    }
182
183    /// Whether this error, or any cause behind it, is a dead relay transport.
184    pub fn marks(error: &anyhow::Error) -> bool {
185        error.downcast_ref::<Self>().is_some()
186    }
187
188    /// Whether the worker was reachable enough to run its liveness probe but
189    /// the proxy then disconnected or failed I/O during a fresh handshake.
190    /// Timeouts are deliberately not marked: a live proxy can be waiting on a
191    /// loaded container runtime or filesystem, which restarting only worsens.
192    pub fn marks_failed_handshake(error: &anyhow::Error) -> bool {
193        error
194            .downcast_ref::<Self>()
195            .is_some_and(|failure| failure.handshake_failed)
196    }
197}
198
199/// Whether an exchange is the handshake that proves the transport carries
200/// traffic at all.
201///
202/// A disconnected handshake proves the new transport never became usable. A
203/// timeout does not: the proxy launcher or worker can still be alive and slow,
204/// so timeout classification is handled separately in [`RelayClient::exchange`].
205#[derive(Clone, Copy, PartialEq, Eq)]
206enum ExchangeKind {
207    Handshake,
208    Call,
209}
210
211/// Why one relay proxy launch failed, and whether the SSH server refused the
212/// connection before authentication rather than the worker being unreachable.
213struct ConnectFailure {
214    error: anyhow::Error,
215    transport_rejected: bool,
216}
217
218impl ConnectFailure {
219    fn plain(error: anyhow::Error) -> Self {
220        Self {
221            error,
222            transport_rejected: false,
223        }
224    }
225}
226
227impl From<anyhow::Error> for ConnectFailure {
228    fn from(error: anyhow::Error) -> Self {
229        Self::plain(error)
230    }
231}
232
233/// Controller-side connection to the durable ACP relay protocol.
234///
235/// This type does not construct transcript state or request unbounded history.
236/// Callers persist bounded attachment pages, then acknowledge only a frontier
237/// that is already durable locally.
238pub struct RelayClient {
239    child: Option<Child>,
240    input: Option<ChildStdin>,
241    output: BufReader<ChildStdout>,
242    request_timeout: Duration,
243    /// Why this connection can no longer be used, once a call gave up on a
244    /// reply that is still in flight. See [`RelayClient::exchange`].
245    abandoned: Option<String>,
246    next_request: u64,
247    connection_nonce: u64,
248    protocol_version: u32,
249    session_id: String,
250    relay_version: String,
251    /// Content address of the executable the worker is running, as reported in
252    /// hello. `None` from a worker built before the field existed.
253    worker_build: Option<String>,
254    latest_ordinal: u64,
255    latest_digest: String,
256}
257
258impl RelayClient {
259    pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
260        Self::connect_with_timeouts(
261            spec,
262            expected_session_id,
263            RELAY_RPC_TIMEOUT,
264            RELAY_HANDSHAKE_TIMEOUT,
265        )
266        .await
267    }
268
269    #[cfg(all(test, unix))]
270    async fn connect_with_timeout(
271        spec: &CommandSpec,
272        expected_session_id: &str,
273        request_timeout: Duration,
274    ) -> Result<Self> {
275        Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
276            .await
277    }
278
279    /// Start a relay proxy and complete its handshake, retrying while the
280    /// remote `sshd` is turning fresh connections away before authentication.
281    ///
282    /// The whole daemon reconnects at once after a restart, which is exactly
283    /// when a host at its `MaxStartups` ceiling drops the surplus. Those
284    /// rejections say nothing about the worker, so escalating one to worker
285    /// recovery would destroy a healthy session.
286    async fn connect_with_timeouts(
287        spec: &CommandSpec,
288        expected_session_id: &str,
289        request_timeout: Duration,
290        handshake_timeout: Duration,
291    ) -> Result<Self> {
292        for attempt in 1..=SSH_RETRY_ATTEMPTS {
293            let outcome = Self::connect_attempt(
294                spec,
295                expected_session_id,
296                request_timeout,
297                handshake_timeout,
298            )
299            .await;
300            let error = match outcome {
301                Ok(client) => return Ok(client),
302                Err(ConnectFailure {
303                    error,
304                    transport_rejected,
305                }) => {
306                    if attempt == SSH_RETRY_ATTEMPTS || !transport_rejected {
307                        return Err(error);
308                    }
309                    error
310                }
311            };
312            let delay = mj_core::targets::ssh_retry_delay(attempt);
313            tracing::warn!(
314                session_id = %expected_session_id,
315                destination = spec.ssh_destination.as_deref().unwrap_or_default(),
316                purpose = %spec.purpose,
317                attempt,
318                attempts = SSH_RETRY_ATTEMPTS,
319                delay_ms = delay.as_millis() as u64,
320                error = %error,
321                "relay proxy was refused by the SSH server before authentication; retrying"
322            );
323            tokio::time::sleep(delay).await;
324        }
325        unreachable!("the final attempt always returns");
326    }
327
328    /// One proxy launch and handshake.
329    ///
330    /// An admission permit is taken before the proxy is spawned and released
331    /// once hello completes: `sshd` counts only unauthenticated connections
332    /// against `MaxStartups`, so the long-lived relay stops occupying a slot
333    /// as soon as it is authenticated and talking.
334    async fn connect_attempt(
335        spec: &CommandSpec,
336        expected_session_id: &str,
337        request_timeout: Duration,
338        handshake_timeout: Duration,
339    ) -> std::result::Result<Self, ConnectFailure> {
340        let permit = match spec.ssh_destination.clone() {
341            Some(destination) => {
342                match tokio::task::spawn_blocking(move || SshAdmission::acquire(&destination)).await
343                {
344                    Ok(permit) => Some(permit),
345                    Err(error) => {
346                        return Err(ConnectFailure::plain(anyhow!(
347                            "SSH admission for the relay proxy was cancelled: {error}"
348                        )));
349                    }
350                }
351            }
352            None => None,
353        };
354        Self::spawn_and_handshake(
355            spec,
356            expected_session_id,
357            request_timeout,
358            handshake_timeout,
359            permit,
360        )
361        .await
362    }
363
364    async fn spawn_and_handshake(
365        spec: &CommandSpec,
366        expected_session_id: &str,
367        request_timeout: Duration,
368        handshake_timeout: Duration,
369        permit: Option<SshPermit>,
370    ) -> std::result::Result<Self, ConnectFailure> {
371        let mut child = Command::new(&spec.program)
372            .args(&spec.args)
373            .envs(&spec.env)
374            .stdin(Stdio::piped())
375            .stdout(Stdio::piped())
376            // Never inherit: the controller owns a TUI alternate screen, so a
377            // child writing to the shared stderr corrupts the display outside
378            // the renderer's buffer. Drain it into the log instead.
379            .stderr(Stdio::piped())
380            .kill_on_drop(true)
381            .spawn()
382            .with_context(|| format!("start session relay proxy for {}", spec.purpose))
383            .map_err(|error| {
384                tracing::warn!(
385                    session_id = %expected_session_id,
386                    operation = "connect",
387                    purpose = %spec.purpose,
388                    error = %error,
389                    "could not start relay proxy"
390                );
391                error
392            })?;
393        let stderr_tail = child.stderr.take().map(|errors| {
394            let purpose = spec.purpose.clone();
395            let session_id = expected_session_id.to_owned();
396            tokio::spawn(drain_proxy_stderr(errors, purpose, session_id))
397        });
398        let input = child
399            .stdin
400            .take()
401            .context("relay proxy stdin unavailable")
402            .map_err(|error| {
403                tracing::warn!(
404                    session_id = %expected_session_id,
405                    operation = "connect",
406                    purpose = %spec.purpose,
407                    error = %error,
408                    "relay proxy did not provide stdin"
409                );
410                error
411            })?;
412        let output = child
413            .stdout
414            .take()
415            .context("relay proxy stdout unavailable")
416            .map_err(|error| {
417                tracing::warn!(
418                    session_id = %expected_session_id,
419                    operation = "connect",
420                    purpose = %spec.purpose,
421                    error = %error,
422                    "relay proxy did not provide stdout"
423                );
424                error
425            })?;
426        let mut nonce_bytes = [0_u8; 8];
427        getrandom::fill(&mut nonce_bytes).map_err(|error| {
428            let error = anyhow!("generate relay request nonce: {error}");
429            tracing::warn!(
430                session_id = %expected_session_id,
431                operation = "connect",
432                error = %error,
433                "could not initialize relay request nonce"
434            );
435            error
436        })?;
437        let mut client = Self {
438            child: Some(child),
439            input: Some(input),
440            output: BufReader::new(output),
441            request_timeout,
442            abandoned: None,
443            next_request: 1,
444            connection_nonce: u64::from_le_bytes(nonce_bytes),
445            protocol_version: RELAY_PROTOCOL_VERSION,
446            // Keep the expected identity from process creation onward so a
447            // handshake failure and the dropped proxy that follows it remain
448            // attributable even when Hello never returns a session ID.
449            session_id: expected_session_id.to_owned(),
450            relay_version: String::new(),
451            worker_build: None,
452            latest_ordinal: 0,
453            latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
454        };
455        match client
456            .complete_handshake(expected_session_id, handshake_timeout)
457            .await
458        {
459            Ok(()) => {
460                // Hello succeeded, so this connection is past authentication
461                // and no longer counts against the server's startup budget.
462                drop(permit);
463                // The drain task keeps logging for the life of the connection.
464                Ok(client)
465            }
466            Err(error) => {
467                // Read the proxy's exit status before killing it: a connection
468                // the server dropped has already exited 255, and that status
469                // is what separates a refused connection from a broken worker.
470                let status = match client.child.as_mut() {
471                    Some(child) => {
472                        match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
473                            Ok(Ok(status)) => status.code(),
474                            // Still running, or unwaitable. Stop the proxy so
475                            // it closes stderr; otherwise a proxy that is
476                            // merely slow would hold the drain task open past
477                            // its grace period and the tail would be lost. The
478                            // child stays in place so dropping `client` reaps
479                            // it as usual.
480                            _ => {
481                                let _ = child.start_kill();
482                                None
483                            }
484                        }
485                    }
486                    None => None,
487                };
488                let tail = Self::proxy_stderr_tail(stderr_tail).await;
489                let transport_rejected = permit.is_some()
490                    && status
491                        .is_some_and(|status| is_transport_rejection(status, &tail.join("\n")));
492                drop(permit);
493                Err(ConnectFailure {
494                    error: Self::attach_proxy_stderr(error, tail),
495                    transport_rejected,
496                })
497            }
498        }
499    }
500
501    /// Collect the proxy's trailing stderr, or nothing if it is still open
502    /// past its detach grace period.
503    async fn proxy_stderr_tail(
504        stderr_tail: Option<tokio::task::JoinHandle<VecDeque<String>>>,
505    ) -> Vec<String> {
506        let Some(handle) = stderr_tail else {
507            return Vec::new();
508        };
509        match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle).await {
510            Ok(Ok(lines)) => lines.into(),
511            _ => Vec::new(),
512        }
513    }
514
515    /// Attach the proxy's own stderr tail to a failed connect. The proxy
516    /// explains failures the controller cannot see any other way, such as a
517    /// worker socket path longer than `sun_path`.
518    fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
519        if lines.is_empty() {
520            return error;
521        }
522        error.context(format!(
523            "relay proxy stderr (last {} lines):\n{}",
524            lines.len(),
525            lines.join("\n")
526        ))
527    }
528
529    /// Exchange `Hello` and record what the relay negotiated.
530    async fn complete_handshake(
531        &mut self,
532        expected_session_id: &str,
533        handshake_timeout: Duration,
534    ) -> Result<()> {
535        let response = self
536            .call_hello(
537                RelayRequest::Hello {
538                    controller_version: env!("CARGO_PKG_VERSION").to_owned(),
539                    supported: RelayVersionRange::CURRENT,
540                },
541                handshake_timeout,
542            )
543            .await?;
544        let RelayResponsePayload::Hello {
545            negotiated,
546            relay_version,
547            session_id,
548            worker_build,
549        } = response
550        else {
551            let error = anyhow!("relay returned an unexpected hello response");
552            log_relay_client_failure(self, "hello", "relay-hello", &error);
553            return Err(error);
554        };
555        if session_id != expected_session_id {
556            let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
557            log_relay_client_failure(self, "hello", "relay-hello", &error);
558            return Err(error);
559        }
560        if !RelayVersionRange::CURRENT.contains(negotiated) {
561            let error = anyhow!(
562                "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
563                RELAY_MIN_PROTOCOL_VERSION,
564                RELAY_PROTOCOL_VERSION
565            );
566            log_relay_client_failure(self, "hello", "relay-hello", &error);
567            return Err(error);
568        }
569        self.protocol_version = negotiated;
570        self.session_id = session_id;
571        self.relay_version = relay_version;
572        self.worker_build = worker_build;
573        Ok(())
574    }
575
576    pub fn session_id(&self) -> &str {
577        &self.session_id
578    }
579
580    pub const fn supports_project_memory_sync(&self) -> bool {
581        self.protocol_version >= 4
582    }
583
584    pub fn relay_version(&self) -> &str {
585        &self.relay_version
586    }
587
588    /// Content address of the executable serving this connection, or `None`
589    /// from a worker too old to report one. A controller reads `None` as
590    /// outdated: it predates the field, so it predates this controller.
591    pub fn worker_build(&self) -> Option<&str> {
592        self.worker_build.as_deref()
593    }
594
595    pub fn protocol_version(&self) -> u32 {
596        self.protocol_version
597    }
598
599    pub fn latest_ordinal(&self) -> u64 {
600        self.latest_ordinal
601    }
602
603    pub fn latest_digest(&self) -> &str {
604        &self.latest_digest
605    }
606
607    pub async fn attach(
608        &mut self,
609        after_ordinal: u64,
610        after_digest: impl Into<String>,
611    ) -> Result<RelayAttachment> {
612        let after_digest = after_digest.into();
613        match self
614            .call_with_timeout(
615                RelayRequest::Attach {
616                    after_ordinal,
617                    after_digest: after_digest.clone(),
618                },
619                RELAY_HISTORY_TIMEOUT,
620            )
621            .await?
622        {
623            RelayResponsePayload::Attached {
624                state,
625                events,
626                through_ordinal,
627                through_digest,
628            } => {
629                let mut cursor = RelayCursor {
630                    ordinal: after_ordinal,
631                    digest: after_digest,
632                };
633                for event in &events {
634                    validate_relay_event(cursor.ordinal, &cursor.digest, event)
635                        .context("verify relay attachment event chain")?;
636                    cursor.ordinal = event.ordinal;
637                    cursor.digest.clone_from(&event.digest);
638                }
639                if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
640                    bail!("relay attachment frontier does not match its event chain");
641                }
642                self.latest_ordinal = state.latest_ordinal;
643                self.latest_digest = state.latest_digest.clone();
644                Ok(RelayAttachment {
645                    state,
646                    events,
647                    through_ordinal,
648                    through_digest,
649                })
650            }
651            _ => bail!("relay returned an unexpected attach response"),
652        }
653    }
654
655    /// Start a bounded catch-up by capturing the relay frontier before the
656    /// caller applies anything. Callers persist `first_page`, request further
657    /// pages with [`Self::next_catch_up_page`], and may acknowledge the fixed
658    /// frontier after all of those pages are durable.
659    pub async fn begin_catch_up(
660        &mut self,
661        after_ordinal: u64,
662        after_digest: impl Into<String>,
663    ) -> Result<RelayCatchUp> {
664        let after_digest = after_digest.into();
665        let first = self.attach(after_ordinal, after_digest.clone()).await?;
666        let frontier = RelayCursor {
667            ordinal: first.state.latest_ordinal,
668            digest: first.state.latest_digest.clone(),
669        };
670        let previous = RelayCursor {
671            ordinal: after_ordinal,
672            digest: after_digest,
673        };
674        let state = first.state.clone();
675        let first_page = clip_catch_up_page(first, &previous, &frontier)?;
676        Ok(RelayCatchUp {
677            state,
678            frontier,
679            first_page,
680        })
681    }
682
683    /// Fetch the next bounded page without chasing events that arrived after
684    /// `frontier` was captured. A response may contain such newer events; the
685    /// returned page is clipped at the exact ordinal-and-digest frontier.
686    pub async fn next_catch_up_page(
687        &mut self,
688        previous: &RelayCursor,
689        frontier: &RelayCursor,
690    ) -> Result<RelayEventPage> {
691        if previous.ordinal >= frontier.ordinal {
692            bail!("relay catch-up is already at its fixed frontier");
693        }
694        let attachment = self
695            .attach(previous.ordinal, previous.digest.clone())
696            .await?;
697        clip_catch_up_page(attachment, previous, frontier)
698    }
699
700    pub async fn acknowledge(
701        &mut self,
702        through_ordinal: u64,
703        through_digest: impl Into<String>,
704    ) -> Result<RelayCursor> {
705        match self
706            .call_with_timeout(
707                RelayRequest::Acknowledge {
708                    through_ordinal,
709                    through_digest: through_digest.into(),
710                },
711                RELAY_ACKNOWLEDGE_TIMEOUT,
712            )
713            .await?
714        {
715            RelayResponsePayload::Acknowledged {
716                through_ordinal,
717                through_digest,
718            } => Ok(RelayCursor {
719                ordinal: through_ordinal,
720                digest: through_digest,
721            }),
722            _ => bail!("relay returned an unexpected acknowledgement response"),
723        }
724    }
725
726    pub async fn status(&mut self) -> Result<RelayOperationalState> {
727        match self.call(RelayRequest::Status).await? {
728            RelayResponsePayload::Status(status) => {
729                self.latest_ordinal = status.latest_ordinal;
730                self.latest_digest = status.latest_digest.clone();
731                Ok(status)
732            }
733            _ => bail!("relay returned an unexpected status response"),
734        }
735    }
736
737    /// Return the fingerprint and freshness of this session's harness
738    /// credentials without exposing the credential bytes.
739    pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
740        credential_snapshot(self.call(RelayRequest::CredentialState).await?)
741    }
742
743    /// Read this session's credential file. Callers must keep these bytes out
744    /// of durable relay observations, logs, and archives.
745    pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
746        match self.call(RelayRequest::ReadCredentials).await? {
747            RelayResponsePayload::Credentials { data } => BASE64
748                .decode(data.as_bytes())
749                .context("decode relay credential payload"),
750            _ => bail!("relay returned an unexpected credential response"),
751        }
752    }
753
754    /// Install credentials into the harness home fixed by this session's
755    /// launch config.
756    pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
757        credential_snapshot(
758            self.call(RelayRequest::InstallCredentials {
759                data: BASE64.encode(bytes),
760            })
761            .await?,
762        )
763    }
764
765    pub async fn github_token_state(
766        &mut self,
767    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
768        github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
769    }
770
771    pub async fn install_github_token(
772        &mut self,
773        token: &str,
774    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
775        github_token_snapshot(
776            self.call(RelayRequest::InstallGithubToken {
777                data: BASE64.encode(token.as_bytes()),
778            })
779            .await?,
780        )
781    }
782
783    pub async fn remove_github_token(
784        &mut self,
785    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
786        github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
787    }
788
789    /// Return the fingerprint of this session's synced skills trees without
790    /// transferring the tree itself.
791    pub async fn skills_state(&mut self) -> Result<mj_core::skills::SkillsSyncState> {
792        skills_sync_state(self.call(RelayRequest::SkillsState).await?)
793    }
794
795    /// Install background text that only the target harness sees, prepended
796    /// to the next real prompt without creating a synthetic transcript turn.
797    pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
798        let request = RelayRequest::InstallPromptContext { text };
799        if !request.supported_at(self.protocol_version) {
800            bail!(
801                "hidden prompt context requires relay protocol {}; this session negotiated {}",
802                request.minimum_protocol(),
803                self.protocol_version
804            );
805        }
806        match self.call(request).await? {
807            RelayResponsePayload::PromptContextInstalled => Ok(()),
808            _ => bail!("relay returned an unexpected prompt-context response"),
809        }
810    }
811
812    pub async fn project_memory_snapshot(
813        &mut self,
814    ) -> Result<(
815        mj_core::project_memory::ProjectMemorySnapshot,
816        mj_core::project_memory::ProjectMemorySnapshot,
817    )> {
818        let request = RelayRequest::ProjectMemorySnapshot;
819        if !request.supported_at(self.protocol_version) {
820            bail!(
821                "project memory synchronization requires relay protocol {}; this session negotiated {}",
822                request.minimum_protocol(),
823                self.protocol_version
824            );
825        }
826        match self.call(request).await? {
827            RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
828                Ok((baseline, replica))
829            }
830            _ => bail!("relay returned an unexpected project-memory response"),
831        }
832    }
833
834    pub async fn install_project_memory_snapshot(
835        &mut self,
836        snapshot: mj_core::project_memory::ProjectMemorySnapshot,
837    ) -> Result<()> {
838        let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
839        if !request.supported_at(self.protocol_version) {
840            bail!(
841                "project memory synchronization requires relay protocol {}; this session negotiated {}",
842                request.minimum_protocol(),
843                self.protocol_version
844            );
845        }
846        match self.call(request).await? {
847            RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
848            _ => bail!("relay returned an unexpected project-memory install response"),
849        }
850    }
851
852    /// Replace this session's synced skills trees with an encoded
853    /// `skills::SkillsArchive`. The destination directories are fixed by
854    /// the session's launch config and the harness skills whitelist.
855    pub async fn install_skills(
856        &mut self,
857        archive_bytes: &[u8],
858    ) -> Result<mj_core::skills::SkillsSyncState> {
859        skills_sync_state(
860            self.call(RelayRequest::InstallSkills {
861                data: BASE64.encode(archive_bytes),
862            })
863            .await?,
864        )
865    }
866
867    /// Copy a verified controller blob to this session before admitting its reference.
868    pub async fn ensure_attachment(
869        &mut self,
870        reference: &mj_core::attachment::AttachmentRef,
871    ) -> Result<()> {
872        anyhow::ensure!(
873            self.protocol_version >= 8,
874            "photo attachments require an updated worker (protocol 8); upgrade the worker and retry"
875        );
876        match self
877            .call(RelayRequest::AttachmentPresent {
878                reference: reference.clone(),
879            })
880            .await?
881        {
882            RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
883            RelayResponsePayload::AttachmentPresent { present: false } => {}
884            _ => bail!("unexpected image presence response"),
885        }
886        let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
887        let reference_copy = reference.clone();
888        let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
889            .await
890            .context("image loading task failed")??;
891        match self
892            .call(RelayRequest::InstallAttachment {
893                reference: reference.clone(),
894                data: BASE64.encode(bytes),
895            })
896            .await?
897        {
898            RelayResponsePayload::AttachmentInstalled => Ok(()),
899            _ => bail!("unexpected image upload response"),
900        }
901    }
902
903    /// Recover the local copy needed for queue editing and resubmission.
904    pub async fn cache_attachment(
905        &mut self,
906        reference: &mj_core::attachment::AttachmentRef,
907    ) -> Result<()> {
908        let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
909        let local = store.clone();
910        let reference_copy = reference.clone();
911        if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
912            .await
913            .context("image lookup task failed")??
914        {
915            return Ok(());
916        }
917        let RelayResponsePayload::AttachmentData { data } = self
918            .call(RelayRequest::ReadAttachment {
919                reference: reference.clone(),
920            })
921            .await?
922        else {
923            bail!("unexpected image download response")
924        };
925        let reference = reference.clone();
926        tokio::task::spawn_blocking(move || {
927            anyhow::ensure!(
928                data.len() <= mj_core::attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
929                "image download is too large"
930            );
931            store.install(&reference, &BASE64.decode(data)?)
932        })
933        .await
934        .context("image caching task failed")?
935    }
936
937    pub async fn submit(
938        &mut self,
939        command_id: impl Into<String>,
940        command: RelayCommand,
941    ) -> Result<u64> {
942        let command_id = command_id.into();
943        if let RelayCommand::Prompt { prompt } = &command {
944            for reference in mj_core::attachment::references(prompt)? {
945                self.ensure_attachment(&reference).await?;
946            }
947        }
948        match self
949            .call(RelayRequest::Submit {
950                command_id: command_id.clone(),
951                command,
952            })
953            .await?
954        {
955            RelayResponsePayload::Accepted {
956                command_id: accepted_id,
957                ordinal,
958            } if accepted_id == command_id => Ok(ordinal),
959            RelayResponsePayload::Accepted {
960                command_id: accepted_id,
961                ..
962            } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
963            _ => bail!("relay returned an unexpected command response"),
964        }
965    }
966
967    /// Start the second-opinion reviewer beside this session, or report the
968    /// running one when it already matches `config`.
969    ///
970    /// The reviewer's profile must already be staged on the target. Starting
971    /// can take as long as opening any harness session, so this uses the
972    /// handshake deadline rather than the bookkeeping one.
973    pub async fn start_reviewer(
974        &mut self,
975        role: Option<&str>,
976        config: ReviewerLaunchConfig,
977    ) -> Result<StartedReviewer> {
978        let request = self.reviewer_request(
979            role,
980            ReviewerRequest::Start {
981                config: Box::new(config),
982            },
983        )?;
984        match self
985            .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
986            .await?
987        {
988            RelayResponsePayload::ReviewerStarted {
989                native_session_id,
990                config_options,
991                reused,
992                state,
993            } => Ok(StartedReviewer {
994                native_session_id,
995                config_options,
996                reused,
997                state: *state,
998            }),
999            _ => bail!("relay returned an unexpected reviewer start response"),
1000        }
1001    }
1002
1003    /// Replay the reviewer's journal from a cursor, exactly as [`Self::attach`]
1004    /// does for the primary.
1005    pub async fn attach_reviewer(
1006        &mut self,
1007        role: Option<&str>,
1008        after_ordinal: u64,
1009        after_digest: impl Into<String>,
1010    ) -> Result<RelayAttachment> {
1011        let after_digest = after_digest.into();
1012        let request = self.reviewer_request(
1013            role,
1014            ReviewerRequest::Attach {
1015                after_ordinal,
1016                after_digest: after_digest.clone(),
1017            },
1018        )?;
1019        let payload = self
1020            .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
1021            .await?;
1022        let RelayResponsePayload::Attached {
1023            state,
1024            events,
1025            through_ordinal,
1026            through_digest,
1027        } = payload
1028        else {
1029            bail!("relay returned an unexpected reviewer attach response");
1030        };
1031        // The reviewer's journal is verified the same way the primary's is: a
1032        // sidecar's history is not exempt from the chain check.
1033        let mut cursor = RelayCursor {
1034            ordinal: after_ordinal,
1035            digest: after_digest,
1036        };
1037        for event in &events {
1038            validate_relay_event(cursor.ordinal, &cursor.digest, event)
1039                .context("verify reviewer attachment event chain")?;
1040            cursor.ordinal = event.ordinal;
1041            cursor.digest.clone_from(&event.digest);
1042        }
1043        if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
1044            bail!("reviewer attachment frontier does not match its event chain");
1045        }
1046        Ok(RelayAttachment {
1047            state,
1048            events,
1049            through_ordinal,
1050            through_digest,
1051        })
1052    }
1053
1054    /// Advance the reviewer's acknowledged frontier so its journal can be
1055    /// pruned once the controller has the events durably.
1056    pub async fn acknowledge_reviewer(
1057        &mut self,
1058        role: Option<&str>,
1059        through_ordinal: u64,
1060        through_digest: impl Into<String>,
1061    ) -> Result<RelayCursor> {
1062        let request = self.reviewer_request(
1063            role,
1064            ReviewerRequest::Acknowledge {
1065                through_ordinal,
1066                through_digest: through_digest.into(),
1067            },
1068        )?;
1069        match self
1070            .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
1071            .await?
1072        {
1073            RelayResponsePayload::Acknowledged {
1074                through_ordinal,
1075                through_digest,
1076            } => Ok(RelayCursor {
1077                ordinal: through_ordinal,
1078                digest: through_digest,
1079            }),
1080            _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
1081        }
1082    }
1083
1084    /// Queue one command on the reviewer's own relay.
1085    pub async fn submit_to_reviewer(
1086        &mut self,
1087        role: Option<&str>,
1088        command_id: impl Into<String>,
1089        command: RelayCommand,
1090    ) -> Result<u64> {
1091        let command_id = command_id.into();
1092        let request = self.reviewer_request(
1093            role,
1094            ReviewerRequest::Submit {
1095                command_id: command_id.clone(),
1096                command,
1097            },
1098        )?;
1099        match self.call(request).await? {
1100            RelayResponsePayload::Accepted {
1101                command_id: accepted_id,
1102                ordinal,
1103            } if accepted_id == command_id => Ok(ordinal),
1104            RelayResponsePayload::Accepted {
1105                command_id: accepted_id,
1106                ..
1107            } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
1108            _ => bail!("relay returned an unexpected reviewer command response"),
1109        }
1110    }
1111
1112    pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
1113        let request = self.reviewer_request(role, ReviewerRequest::Status)?;
1114        match self.call(request).await? {
1115            RelayResponsePayload::Status(status) => Ok(status),
1116            _ => bail!("relay returned an unexpected reviewer status response"),
1117        }
1118    }
1119
1120    /// Answer a form the reviewer's harness is waiting on.
1121    pub async fn respond_to_reviewer(
1122        &mut self,
1123        role: Option<&str>,
1124        elicitation_id: String,
1125        response: ElicitationResponse,
1126    ) -> Result<()> {
1127        let request = self.reviewer_request(
1128            role,
1129            ReviewerRequest::RespondElicitation {
1130                elicitation_id: elicitation_id.clone(),
1131                response,
1132            },
1133        )?;
1134        match self.call(request).await? {
1135            RelayResponsePayload::ElicitationResolved {
1136                elicitation_id: resolved,
1137            } if resolved == elicitation_id => Ok(()),
1138            RelayResponsePayload::ElicitationResolved {
1139                elicitation_id: resolved,
1140            } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1141            _ => bail!("relay returned an unexpected reviewer elicitation response"),
1142        }
1143    }
1144
1145    /// Cancel any reviewer turn in flight and stop its process group, keeping
1146    /// its staged profile, native session and journal for the next review.
1147    pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
1148        let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
1149        match self
1150            .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
1151            .await?
1152        {
1153            RelayResponsePayload::ReviewerPaused => Ok(()),
1154            _ => bail!("relay returned an unexpected reviewer pause response"),
1155        }
1156    }
1157
1158    /// Report what every workspace repository changed since the review
1159    /// baselines the controller holds.
1160    pub async fn capture_review_delta(
1161        &mut self,
1162        role: Option<&str>,
1163        baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
1164    ) -> Result<Vec<mj_core::relay::RepoDelta>> {
1165        let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
1166        match self
1167            .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1168            .await?
1169        {
1170            RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
1171            _ => bail!("relay returned an unexpected review capture response"),
1172        }
1173    }
1174
1175    /// Record the trees a completed review reviewed through, so the next
1176    /// review starts from them.
1177    pub async fn advance_review_baseline(
1178        &mut self,
1179        role: Option<&str>,
1180        trees: std::collections::BTreeMap<std::path::PathBuf, String>,
1181    ) -> Result<()> {
1182        let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
1183        match self
1184            .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1185            .await?
1186        {
1187            RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
1188            _ => bail!("relay returned an unexpected review baseline response"),
1189        }
1190    }
1191
1192    /// Run Bifrost's semantic diff analysis over the captured trees. It can
1193    /// take minutes on a large changeset, so it carries its own budget.
1194    pub async fn analyze_review_delta(
1195        &mut self,
1196        role: Option<&str>,
1197        repositories: Vec<mj_core::relay::AnalyzeDeltaRepository>,
1198    ) -> Result<String> {
1199        let request =
1200            self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
1201        match self
1202            .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
1203            .await?
1204        {
1205            RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
1206            _ => bail!("relay returned an unexpected review analysis response"),
1207        }
1208    }
1209
1210    /// Collect the specialist lanes the review supervisor asked for since the
1211    /// last call.
1212    pub async fn take_lane_dispatches(
1213        &mut self,
1214    ) -> Result<Vec<mj_core::review::lanes::ReviewSubagentRequest>> {
1215        let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
1216        match self.call(request).await? {
1217            RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
1218            _ => bail!("relay returned an unexpected lane dispatch response"),
1219        }
1220    }
1221
1222    /// Wraps a reviewer action, refusing it on a worker too old to know what a
1223    /// reviewer is rather than sending a method it would reject as unknown.
1224    fn reviewer_request(
1225        &self,
1226        role: Option<&str>,
1227        request: ReviewerRequest,
1228    ) -> Result<RelayRequest> {
1229        let request = RelayRequest::Reviewer {
1230            role: role.map(str::to_owned),
1231            request,
1232        };
1233        if !request.supported_at(self.protocol_version) {
1234            bail!(
1235                "a second opinion requires relay protocol {}; this session negotiated {}",
1236                request.minimum_protocol(),
1237                self.protocol_version
1238            );
1239        }
1240        Ok(request)
1241    }
1242
1243    /// Answer an ACP form over the live relay connection. User-entered content
1244    /// is intentionally excluded from the relay's durable command path.
1245    pub async fn respond_elicitation(
1246        &mut self,
1247        elicitation_id: String,
1248        response: ElicitationResponse,
1249    ) -> Result<()> {
1250        let request = RelayRequest::RespondElicitation {
1251            elicitation_id: elicitation_id.clone(),
1252            response,
1253        };
1254        if !request.supported_at(self.protocol_version) {
1255            bail!(
1256                "elicitation responses require relay protocol {}; this session negotiated {}",
1257                request.minimum_protocol(),
1258                self.protocol_version
1259            );
1260        }
1261        match self.call(request).await? {
1262            RelayResponsePayload::ElicitationResolved {
1263                elicitation_id: resolved,
1264            } if resolved == elicitation_id => Ok(()),
1265            RelayResponsePayload::ElicitationResolved {
1266                elicitation_id: resolved,
1267            } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1268            _ => bail!("relay returned an unexpected elicitation response"),
1269        }
1270    }
1271
1272    /// Ask the live worker to stop one process-local background task.
1273    pub async fn stop_background_task(&mut self, background_task_id: String) -> Result<()> {
1274        let request = RelayRequest::StopBackgroundTask {
1275            background_task_id: background_task_id.clone(),
1276        };
1277        if !request.supported_at(self.protocol_version) {
1278            bail!(
1279                "background task controls require relay protocol {}; this session negotiated {}",
1280                request.minimum_protocol(),
1281                self.protocol_version
1282            );
1283        }
1284        match self.call(request).await? {
1285            RelayResponsePayload::BackgroundTaskStopRequested {
1286                background_task_id: stopped,
1287            } if stopped == background_task_id => Ok(()),
1288            RelayResponsePayload::BackgroundTaskStopRequested {
1289                background_task_id: stopped,
1290            } => {
1291                bail!("relay stopped background task {stopped:?}, expected {background_task_id:?}")
1292            }
1293            _ => bail!("relay returned an unexpected background task stop response"),
1294        }
1295    }
1296
1297    pub async fn subagent_requests(
1298        &mut self,
1299    ) -> Result<(
1300        Vec<mj_core::subagent::SubagentToolRequest>,
1301        Vec<mj_core::subagent::SubagentToolResult>,
1302    )> {
1303        let request = RelayRequest::SubagentRequests;
1304        if !request.supported_at(self.protocol_version) {
1305            return Ok((Vec::new(), Vec::new()));
1306        }
1307        match self.call(request).await? {
1308            RelayResponsePayload::SubagentRequests { requests, results } => Ok((requests, results)),
1309            _ => bail!("relay returned an unexpected sub-agent request response"),
1310        }
1311    }
1312
1313    pub async fn complete_subagent_request(
1314        &mut self,
1315        result: mj_core::subagent::SubagentToolResult,
1316    ) -> Result<()> {
1317        let request = RelayRequest::CompleteSubagentRequest { result };
1318        if !request.supported_at(self.protocol_version) {
1319            bail!(
1320                "sub-agent tools require relay protocol {}",
1321                request.minimum_protocol()
1322            );
1323        }
1324        match self.call(request).await? {
1325            RelayResponsePayload::SubagentRequestCompleted => Ok(()),
1326            _ => bail!("relay returned an unexpected sub-agent completion response"),
1327        }
1328    }
1329
1330    pub async fn detach(mut self) -> Result<()> {
1331        self.input
1332            .take()
1333            .expect("connected relay owns proxy stdin")
1334            .shutdown()
1335            .await
1336            .context("close relay proxy stdin")?;
1337        let mut child = self.child.take().expect("connected relay owns proxy child");
1338        match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1339            Ok(status) => {
1340                status.context("wait for relay proxy")?;
1341            }
1342            Err(_) => {
1343                if let Err(error) = child.start_kill().context("stop relay proxy") {
1344                    tracing::warn!(
1345                        session_id = %self.session_id,
1346                        operation = "detach",
1347                        %error,
1348                        "could not stop relay proxy after detach timeout"
1349                    );
1350                    return Err(error);
1351                }
1352                if let Err(error) = child.wait().await {
1353                    tracing::warn!(
1354                        session_id = %self.session_id,
1355                        operation = "detach",
1356                        %error,
1357                        "could not reap relay proxy after stopping it"
1358                    );
1359                }
1360            }
1361        }
1362        Ok(())
1363    }
1364
1365    async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1366        self.call_with_timeout(request, self.request_timeout).await
1367    }
1368
1369    async fn call_with_timeout(
1370        &mut self,
1371        request: RelayRequest,
1372        timeout: Duration,
1373    ) -> Result<RelayResponsePayload> {
1374        let operation = request.method_name();
1375        let request_id = self.request_id();
1376        let envelope = RelayRequestEnvelope {
1377            request_id: request_id.clone(),
1378            protocol_version: self.protocol_version,
1379            request,
1380        };
1381        let line = match self
1382            .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1383            .await
1384        {
1385            Ok(line) => line,
1386            Err(error) => {
1387                log_relay_client_failure(self, operation, &request_id, &error);
1388                return Err(error);
1389            }
1390        };
1391        let result = decode_relay_response(&line, &request_id, self.protocol_version)
1392            .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1393        if let Err(error) = &result {
1394            log_relay_client_failure(self, operation, &request_id, error);
1395        }
1396        result
1397    }
1398
1399    async fn call_hello(
1400        &mut self,
1401        request: RelayRequest,
1402        timeout: Duration,
1403    ) -> Result<RelayResponsePayload> {
1404        let operation = request.method_name();
1405        let request_id = self.request_id();
1406        let envelope = RelayRequestEnvelope {
1407            request_id: request_id.clone(),
1408            protocol_version: RELAY_PROTOCOL_VERSION,
1409            request,
1410        };
1411        let line = match self
1412            .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1413            .await
1414        {
1415            Ok(line) => line,
1416            Err(error) => {
1417                log_relay_client_failure(self, operation, &request_id, &error);
1418                return Err(error);
1419            }
1420        };
1421        let result = decode_relay_hello_response(&line, &request_id);
1422        if let Err(error) = &result {
1423            log_relay_client_failure(self, operation, &request_id, error);
1424        }
1425        result
1426    }
1427
1428    /// Write one request frame and read the reply that belongs to it.
1429    ///
1430    /// The connection is strictly sequential, so giving up on a reply does not
1431    /// cancel it: the relay may still answer, and that answer would be read as
1432    /// the *next* call's response. Timeouts therefore abandon the connection
1433    /// rather than the single call. Every later call fails immediately with the
1434    /// true cause, so callers reconnect deliberately instead of chasing a
1435    /// mismatched response ID. This matters most where a short bookkeeping
1436    /// deadline and a long compaction deadline share one connection.
1437    async fn exchange(
1438        &mut self,
1439        envelope: &RelayRequestEnvelope,
1440        operation: &str,
1441        timeout: Duration,
1442        kind: ExchangeKind,
1443    ) -> Result<String> {
1444        if let Some(reason) = &self.abandoned {
1445            bail!("{reason}");
1446        }
1447        let mut frame = serde_json::to_vec(envelope)?;
1448        if frame.len() > MAX_FRAME_BYTES {
1449            bail!("relay {operation} request frame is too large");
1450        }
1451        frame.push(b'\n');
1452        let session_id = self.session_id.clone();
1453        let exchanged = tokio::time::timeout(timeout, async {
1454            self.input
1455                .as_mut()
1456                .expect("connected relay owns proxy stdin")
1457                .write_all(&frame)
1458                .await
1459                .map_err(|error| RelayTransportDead::from_io(error, kind))
1460                .with_context(|| format!("write relay {operation} request"))?;
1461            self.input
1462                .as_mut()
1463                .expect("connected relay owns proxy stdin")
1464                .flush()
1465                .await
1466                .map_err(|error| RelayTransportDead::from_io(error, kind))
1467                .with_context(|| format!("flush relay {operation} request"))?;
1468            let response = read_bounded_frame(&mut self.output, kind);
1469            tokio::pin!(response);
1470            let response = tokio::select! {
1471                response = &mut response => response,
1472                () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1473                    tracing::warn!(
1474                        %session_id,
1475                        %operation,
1476                        warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1477                        timeout_seconds = timeout.as_secs_f64(),
1478                        "relay operation is still waiting for its response"
1479                    );
1480                    response.await
1481                }
1482            };
1483            response
1484                .with_context(|| format!("read relay {operation} response"))?
1485                .ok_or_else(|| {
1486                    anyhow::Error::new(RelayTransportDead::during_exchange(
1487                        format!("relay proxy disconnected during {operation}"),
1488                        kind,
1489                    ))
1490                })
1491        })
1492        .await;
1493        match exchanged {
1494            Ok(line) => line,
1495            Err(_elapsed) => {
1496                let seconds = timeout.as_secs_f64();
1497                tracing::warn!(
1498                    %session_id,
1499                    %operation,
1500                    timeout_seconds = seconds,
1501                    "relay operation timed out; abandoning its sequential connection"
1502                );
1503                self.abandoned = Some(format!(
1504                    "relay connection abandoned after {operation} timed out after {seconds} seconds"
1505                ));
1506                let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1507                Err(anyhow!(timed_out))
1508            }
1509        }
1510    }
1511
1512    fn request_id(&mut self) -> String {
1513        let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1514        self.next_request = self.next_request.wrapping_add(1);
1515        id
1516    }
1517}
1518
1519/// Keep transport, protocol, and explicit relay rejections visible at the
1520/// point where a request fails. Callers often turn these into a user-facing
1521/// string or a retry, which otherwise loses the operation and request ID that
1522/// make concurrent session failures diagnosable.
1523fn log_relay_client_failure(
1524    client: &RelayClient,
1525    operation: &str,
1526    request_id: &str,
1527    error: &anyhow::Error,
1528) {
1529    let rejection = error.chain().find_map(|cause| {
1530        cause
1531            .downcast_ref::<RelayRejected>()
1532            .map(|rejected| &rejected.0)
1533    });
1534    let transport_dead = RelayTransportDead::marks(error);
1535    match rejection {
1536        Some(rejection) => tracing::warn!(
1537            session_id = %client.session_id,
1538            relay_version = %client.relay_version,
1539            %operation,
1540            %request_id,
1541            relay_error_code = ?rejection.code,
1542            relay_retryable = rejection.retryable,
1543            transport_dead,
1544            error = %error,
1545            "relay request rejected"
1546        ),
1547        None => tracing::warn!(
1548            session_id = %client.session_id,
1549            relay_version = %client.relay_version,
1550            %operation,
1551            %request_id,
1552            transport_dead,
1553            error = %error,
1554            "relay request failed"
1555        ),
1556    }
1557}
1558
1559impl Drop for RelayClient {
1560    fn drop(&mut self) {
1561        // Async owners call `detach` so EOF has a bounded chance to propagate
1562        // through Podman or SSH before the launcher is stopped. Drop is the
1563        // shutdown-safe fallback: it may run while Tokio's drivers are already
1564        // gone, so its bounded reaper cannot use runtime work or Tokio timers.
1565        drop(self.input.take());
1566        let Some(child) = self.child.take() else {
1567            return;
1568        };
1569        let session_id = self.session_id.clone();
1570        if let Err(error) = std::thread::Builder::new()
1571            .name("hel-relay-reaper".into())
1572            .spawn(move || reap_dropped_relay_proxy(child, session_id))
1573        {
1574            tracing::warn!(
1575                session_id = %self.session_id,
1576                %error,
1577                "could not start dropped relay proxy reaper"
1578            );
1579        }
1580    }
1581}
1582
1583/// Let EOF traverse a proxy launcher, then stop and reap it without relying on
1584/// an async runtime that may already be shutting down.
1585fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1586    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1587    loop {
1588        match child.try_wait() {
1589            Ok(Some(status)) => {
1590                if !status.success() {
1591                    tracing::warn!(
1592                        %session_id,
1593                        %status,
1594                        "dropped relay proxy exited unsuccessfully"
1595                    );
1596                }
1597                return;
1598            }
1599            Ok(None) if Instant::now() < deadline => {
1600                std::thread::sleep(RELAY_PROXY_REAP_POLL);
1601            }
1602            Ok(None) => break,
1603            Err(error) => {
1604                tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1605                return;
1606            }
1607        }
1608    }
1609
1610    if let Err(error) = child.start_kill()
1611        && error.kind() != std::io::ErrorKind::NotFound
1612    {
1613        tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1614        return;
1615    }
1616    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1617    loop {
1618        match child.try_wait() {
1619            Ok(Some(_)) => return,
1620            Ok(None) if Instant::now() < deadline => {
1621                std::thread::sleep(RELAY_PROXY_REAP_POLL);
1622            }
1623            Ok(None) => {
1624                tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1625                return;
1626            }
1627            Err(error) => {
1628                tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1629                return;
1630            }
1631        }
1632    }
1633}
1634
1635fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1636    match payload {
1637        RelayResponsePayload::CredentialState {
1638            present,
1639            fingerprint,
1640            freshness_epoch_ms,
1641        } => Ok(CredentialSnapshot {
1642            present,
1643            fingerprint,
1644            freshness_epoch_ms,
1645        }),
1646        _ => bail!("relay returned an unexpected credential state response"),
1647    }
1648}
1649
1650fn skills_sync_state(payload: RelayResponsePayload) -> Result<mj_core::skills::SkillsSyncState> {
1651    match payload {
1652        RelayResponsePayload::SkillsState {
1653            present,
1654            fingerprint,
1655        } => Ok(mj_core::skills::SkillsSyncState {
1656            present,
1657            fingerprint,
1658        }),
1659        _ => bail!("relay returned an unexpected skills state response"),
1660    }
1661}
1662
1663fn github_token_snapshot(
1664    payload: RelayResponsePayload,
1665) -> Result<mj_core::credentials::GithubTokenSnapshot> {
1666    match payload {
1667        RelayResponsePayload::GithubTokenState {
1668            present,
1669            fingerprint,
1670        } => Ok(mj_core::credentials::GithubTokenSnapshot {
1671            present,
1672            fingerprint,
1673        }),
1674        _ => bail!("relay returned an unexpected GitHub token state response"),
1675    }
1676}
1677
1678async fn read_bounded_frame(
1679    reader: &mut (impl AsyncBufRead + Unpin),
1680    kind: ExchangeKind,
1681) -> Result<Option<String>> {
1682    read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1683}
1684
1685async fn read_bounded_frame_with_limit(
1686    reader: &mut (impl AsyncBufRead + Unpin),
1687    maximum_bytes: usize,
1688    kind: ExchangeKind,
1689) -> Result<Option<String>> {
1690    let mut frame = Vec::new();
1691    loop {
1692        // A failed read and a half-written frame are transport deaths; the
1693        // limit and encoding failures below are protocol violations that a
1694        // worker restart would not fix, so only these two carry the marker.
1695        let available = reader
1696            .fill_buf()
1697            .await
1698            .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1699        if available.is_empty() {
1700            if frame.is_empty() {
1701                return Ok(None);
1702            }
1703            return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1704                "relay proxy disconnected in the middle of a response frame",
1705                kind,
1706            )));
1707        }
1708        let newline = available.iter().position(|byte| *byte == b'\n');
1709        let consumed = newline.map_or(available.len(), |position| position + 1);
1710        let payload = newline.map_or(available, |position| &available[..position]);
1711        if frame.len().saturating_add(payload.len()) > maximum_bytes {
1712            bail!("relay response frame is too large");
1713        }
1714        frame.extend_from_slice(payload);
1715        reader.consume(consumed);
1716        if newline.is_some() {
1717            if frame.last() == Some(&b'\r') {
1718                frame.pop();
1719            }
1720            return String::from_utf8(frame)
1721                .context("relay response is not UTF-8")
1722                .map(Some);
1723        }
1724    }
1725}
1726
1727fn clip_catch_up_page(
1728    page: RelayAttachment,
1729    previous: &RelayCursor,
1730    frontier: &RelayCursor,
1731) -> Result<RelayEventPage> {
1732    if previous.ordinal > frontier.ordinal {
1733        bail!("relay catch-up starts beyond its fixed frontier");
1734    }
1735    if previous.ordinal == frontier.ordinal {
1736        if previous != frontier {
1737            bail!("relay catch-up cursor digest differs from its fixed frontier");
1738        }
1739        if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1740            bail!("relay attachment advanced beyond its advertised frontier");
1741        }
1742        return Ok(RelayEventPage {
1743            events: Vec::new(),
1744            through_ordinal: previous.ordinal,
1745            through_digest: previous.digest.clone(),
1746        });
1747    }
1748    if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1749        bail!("relay catch-up page did not advance");
1750    }
1751    if page.through_ordinal <= frontier.ordinal {
1752        let through = RelayCursor {
1753            ordinal: page.through_ordinal,
1754            digest: page.through_digest.clone(),
1755        };
1756        if through.ordinal == frontier.ordinal && through != *frontier {
1757            bail!("relay catch-up page digest differs from its fixed frontier");
1758        }
1759        return Ok(RelayEventPage {
1760            events: page.events,
1761            through_ordinal: through.ordinal,
1762            through_digest: through.digest,
1763        });
1764    }
1765
1766    let events = page
1767        .events
1768        .into_iter()
1769        .take_while(|event| event.ordinal <= frontier.ordinal)
1770        .collect::<Vec<_>>();
1771    let reached = events
1772        .last()
1773        .map(|event| RelayCursor {
1774            ordinal: event.ordinal,
1775            digest: event.digest.clone(),
1776        })
1777        .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1778    if reached != *frontier {
1779        bail!("relay catch-up page does not contain its fixed frontier");
1780    }
1781    Ok(RelayEventPage {
1782        events,
1783        through_ordinal: reached.ordinal,
1784        through_digest: reached.digest,
1785    })
1786}
1787
1788fn decode_relay_response(
1789    line: &str,
1790    request_id: &str,
1791    protocol: u32,
1792) -> Result<RelayResponsePayload> {
1793    let response: RelayResponseEnvelope =
1794        serde_json::from_str(line).context("decode relay response")?;
1795    if response.request_id != request_id {
1796        bail!(
1797            "relay response ID mismatch: expected {request_id}, got {}",
1798            response.request_id
1799        );
1800    }
1801    if response.protocol_version != protocol {
1802        bail!(
1803            "relay response protocol mismatch: expected {protocol}, got {}",
1804            response.protocol_version
1805        );
1806    }
1807    match response.body {
1808        RelayResponseBody::Ok { payload } => Ok(payload),
1809        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1810    }
1811}
1812
1813fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1814    let response: RelayResponseEnvelope =
1815        serde_json::from_str(line).context("decode relay hello response")?;
1816    if response.request_id != request_id {
1817        bail!(
1818            "relay response ID mismatch: expected {request_id}, got {}",
1819            response.request_id
1820        );
1821    }
1822    match response.body {
1823        RelayResponseBody::Ok {
1824            payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1825        } => {
1826            if response.protocol_version != negotiated {
1827                bail!(
1828                    "relay hello envelope uses protocol {}, negotiated {negotiated}",
1829                    response.protocol_version
1830                );
1831            }
1832            Ok(payload)
1833        }
1834        RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1835        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1836    }
1837}
1838
1839pub struct CredentialSyncCoordinator {
1840    handle: CredentialSyncHandle,
1841    results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1842}
1843
1844impl CredentialSyncCoordinator {
1845    pub fn spawn() -> Self {
1846        let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1847        let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1848        let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1849        let (results_tx, results_rx) = mpsc::unbounded_channel();
1850        tokio::spawn(async move {
1851            let mut tick = tokio::time::interval_at(
1852                tokio::time::Instant::now() + SYNC_INTERVAL,
1853                SYNC_INTERVAL,
1854            );
1855            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1856            // A pull rewrites the canonical file, so one profile is never
1857            // reconciled twice at once.
1858            let mut busy = BTreeSet::<String>::new();
1859            let mut queue = VecDeque::<SyncTrigger>::new();
1860            loop {
1861                tokio::select! {
1862                    _ = tick.tick() => {
1863                        for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1864                            enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1865                        }
1866                    }
1867                    changed = targets_rx.changed() => {
1868                        if changed.is_err() { break; }
1869                        for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1870                            enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1871                        }
1872                    }
1873                    trigger = triggers_rx.recv() => {
1874                        let Some(trigger) = trigger else { break };
1875                        enqueue(&mut queue, trigger);
1876                    }
1877                    completed = completed_rx.recv() => {
1878                        let Some(result) = completed else { break };
1879                        busy.remove(&result.profile_id);
1880                        if result.trigger.is_some()
1881                            || result.failure.is_some()
1882                            || !result.outcomes.is_empty()
1883                        {
1884                            let profile_id = result.profile_id.clone();
1885                            if results_tx.send(result).is_err() {
1886                                tracing::debug!(
1887                                    %profile_id,
1888                                    operation = "credential_sync_result",
1889                                    "credential sync result receiver was already closed"
1890                                );
1891                            }
1892                        }
1893                    }
1894                }
1895
1896                let mut deferred = VecDeque::new();
1897                while let Some(trigger) = queue.pop_front() {
1898                    if busy.contains(&trigger.profile_id) {
1899                        deferred.push_back(trigger);
1900                        continue;
1901                    }
1902                    let targets: Vec<_> = targets_rx
1903                        .borrow()
1904                        .iter()
1905                        .filter(|target| target.profile_id == trigger.profile_id)
1906                        .cloned()
1907                        .collect();
1908                    if targets.is_empty() {
1909                        if trigger.cause.is_some() {
1910                            let profile_id = trigger.profile_id.clone();
1911                            if results_tx
1912                                .send(CredentialSyncResult {
1913                                    profile_id: trigger.profile_id,
1914                                    trigger: trigger.cause,
1915                                    failure: None,
1916                                    outcomes: Vec::new(),
1917                                })
1918                                .is_err()
1919                            {
1920                                tracing::debug!(
1921                                    %profile_id,
1922                                    operation = "credential_sync_result",
1923                                    "credential sync result receiver was already closed"
1924                                );
1925                            }
1926                        }
1927                        continue;
1928                    }
1929                    busy.insert(trigger.profile_id.clone());
1930                    let completed_tx = completed_tx.clone();
1931                    let handle = tokio::runtime::Handle::current();
1932                    // The blocking join is awaited so a panicked reconcile is
1933                    // reported and its profile always leaves the busy set.
1934                    tokio::spawn(async move {
1935                        let joined = tokio::task::spawn_blocking(move || {
1936                            handle.block_on(reconcile_profile(&targets))
1937                        })
1938                        .await;
1939                        let (failure, outcomes) = match joined {
1940                            Ok(outcomes) => (None, outcomes),
1941                            Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1942                        };
1943                        let profile_id = trigger.profile_id.clone();
1944                        if completed_tx
1945                            .send(CredentialSyncResult {
1946                                profile_id: trigger.profile_id,
1947                                trigger: trigger.cause,
1948                                failure,
1949                                outcomes,
1950                            })
1951                            .is_err()
1952                        {
1953                            tracing::debug!(
1954                                %profile_id,
1955                                operation = "credential_sync_completion",
1956                                "credential sync coordinator stopped before receiving completion"
1957                            );
1958                        }
1959                    });
1960                }
1961                queue = deferred;
1962            }
1963        });
1964        Self {
1965            handle: CredentialSyncHandle {
1966                targets: Arc::new(targets_tx),
1967                triggers: triggers_tx,
1968            },
1969            results: results_rx,
1970        }
1971    }
1972
1973    pub fn handle(&self) -> CredentialSyncHandle {
1974        self.handle.clone()
1975    }
1976
1977    pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1978        self.results.try_recv().ok()
1979    }
1980
1981    /// Waits for the next finished sync.
1982    ///
1983    /// Event-driven loops select on this instead of polling; `None` means the
1984    /// coordinator task has stopped. Cancel-safe, so a lost `select!` race
1985    /// keeps the result queued.
1986    pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1987        self.results.recv().await
1988    }
1989}
1990
1991/// Reconcile one profile with every live session that runs it.
1992///
1993/// A pull makes every other session's copy stale by definition, so the pass
1994/// runs again once with the new canonical bytes. Two passes are enough: the
1995/// second cannot pull anything the first did not already see unless a harness
1996/// refreshed mid-cycle, and that lands in the next cycle.
1997async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1998    let github_token = targets
1999        .iter()
2000        .any(|target| target.sync_github_token)
2001        .then(crate::controller::controller_github_token)
2002        .flatten();
2003    let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
2004    for pass in 0..2 {
2005        let mut pulled = false;
2006        for target in targets {
2007            match reconcile_session(target, github_token.as_deref()).await {
2008                Ok(actions) if actions.is_empty() => {}
2009                Ok(actions) => {
2010                    pulled |= actions.contains(&CredentialSyncAction::Pulled);
2011                    outcomes.insert(
2012                        target.session_id.clone(),
2013                        CredentialSyncOutcome {
2014                            session_id: target.session_id.clone(),
2015                            outcome: Ok(actions),
2016                        },
2017                    );
2018                }
2019                Err(error) => {
2020                    tracing::warn!(
2021                        session_id = %target.session_id,
2022                        profile_id = %target.profile_id,
2023                        pass = pass + 1,
2024                        error = %error,
2025                        "credential synchronization failed for relay session"
2026                    );
2027                    outcomes.insert(
2028                        target.session_id.clone(),
2029                        CredentialSyncOutcome {
2030                            session_id: target.session_id.clone(),
2031                            outcome: Err(format!("{error:#}")),
2032                        },
2033                    );
2034                }
2035            }
2036        }
2037        if !pulled || pass == 1 {
2038            break;
2039        }
2040    }
2041    outcomes.into_values().collect()
2042}
2043
2044/// Returns every action taken; an empty list means the copies already agree.
2045async fn reconcile_session(
2046    target: &CredentialSyncTarget,
2047    github_token: Option<&str>,
2048) -> Result<Vec<CredentialSyncAction>> {
2049    let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
2050    let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
2051    let canonical_skills = mj_core::skills::collect_skills(target.harness, &target.profile_home)
2052        .with_context(|| {
2053            format!(
2054                "collect canonical skills for profile {} from {}",
2055                target.profile_id,
2056                target.profile_home.display()
2057            )
2058        })?;
2059    let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
2060    let result = reconcile_connected(
2061        &mut client,
2062        target,
2063        &canonical_path,
2064        &canonical,
2065        &canonical_bytes,
2066        &canonical_skills,
2067        github_token,
2068    )
2069    .await;
2070    // Detach even when the exchange failed; the worker and harness keep
2071    // running either way. A failed detach only leaks a short-lived proxy, so it
2072    // is reported rather than turned into a sync failure.
2073    if let Err(error) = client.detach().await {
2074        tracing::warn!(
2075            session_id = %target.session_id,
2076            "could not close the credential sync connection: {error:#}"
2077        );
2078    }
2079    result
2080}
2081
2082async fn reconcile_connected(
2083    client: &mut RelayClient,
2084    target: &CredentialSyncTarget,
2085    canonical_path: &Path,
2086    canonical: &CredentialSnapshot,
2087    canonical_bytes: &[u8],
2088    canonical_skills: &mj_core::skills::SkillsArchive,
2089    github_token: Option<&str>,
2090) -> Result<Vec<CredentialSyncAction>> {
2091    let mut actions = Vec::new();
2092    let session = client.credential_state().await?;
2093    match reconcile(canonical, &session) {
2094        SyncAction::None => {
2095            if canonical.present
2096                && session.present
2097                && canonical.fingerprint != session.fingerprint
2098                && canonical.freshness_epoch_ms.is_none()
2099                && session.freshness_epoch_ms.is_none()
2100            {
2101                tracing::warn!(
2102                    session_id = %target.session_id,
2103                    profile_id = %target.profile_id,
2104                    "credential copies differ but neither reports a refresh time; leaving both alone"
2105                );
2106            }
2107        }
2108        SyncAction::Push => {
2109            client.install_credentials(canonical_bytes).await?;
2110            actions.push(CredentialSyncAction::Pushed);
2111        }
2112        SyncAction::Pull => {
2113            let bytes = client.read_credentials().await?;
2114            validate_credential_payload(target.harness, &bytes).with_context(|| {
2115                format!(
2116                    "session {} returned an unusable credential file",
2117                    target.session_id
2118                )
2119            })?;
2120            write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
2121                format!(
2122                    "install fresher credentials from session {} for profile {}",
2123                    target.session_id, target.profile_id
2124                )
2125            })?;
2126            actions.push(CredentialSyncAction::Pulled);
2127        }
2128    }
2129    if reconcile_skills(client, target, canonical_skills).await? {
2130        actions.push(CredentialSyncAction::SkillsPushed);
2131    }
2132    if target.sync_github_token
2133        && let Some(action) = reconcile_github_token(client, target, github_token).await?
2134    {
2135        actions.push(action);
2136    }
2137    Ok(actions)
2138}
2139
2140async fn reconcile_github_token(
2141    client: &mut RelayClient,
2142    target: &CredentialSyncTarget,
2143    canonical: Option<&str>,
2144) -> Result<Option<CredentialSyncAction>> {
2145    let session = match client.github_token_state().await {
2146        Ok(state) => state,
2147        Err(error) if sync_method_unsupported(&error) => {
2148            tracing::debug!(
2149                session_id = %target.session_id,
2150                profile_id = %target.profile_id,
2151                "worker predates GitHub token sync; skipping until the target is re-provisioned"
2152            );
2153            return Ok(None);
2154        }
2155        Err(error) => return Err(error),
2156    };
2157    match canonical {
2158        Some(token) => {
2159            let canonical = mj_core::credentials::GithubTokenSnapshot::of(token);
2160            if session == canonical {
2161                return Ok(None);
2162            }
2163            let installed = client.install_github_token(token).await?;
2164            if installed != canonical {
2165                bail!(
2166                    "session {} GitHub token fingerprint does not match the controller after install",
2167                    target.session_id
2168                );
2169            }
2170            Ok(Some(CredentialSyncAction::GithubTokenPushed))
2171        }
2172        None if session.present => {
2173            let removed = client.remove_github_token().await?;
2174            if removed.present {
2175                bail!(
2176                    "session {} retained its GitHub token after removal",
2177                    target.session_id
2178                );
2179            }
2180            Ok(Some(CredentialSyncAction::GithubTokenRemoved))
2181        }
2182        None => Ok(None),
2183    }
2184}
2185
2186/// Converge the session's synced skills trees onto the canonical archive.
2187/// Returns true when a push happened. Workers old enough to predate skills
2188/// sync answer the unknown method with `InvalidRequest`; those sessions are
2189/// skipped quietly until their target is re-provisioned.
2190async fn reconcile_skills(
2191    client: &mut RelayClient,
2192    target: &CredentialSyncTarget,
2193    canonical: &mj_core::skills::SkillsArchive,
2194) -> Result<bool> {
2195    let canonical_state = canonical.state();
2196    let session = match client.skills_state().await {
2197        Ok(state) => state,
2198        Err(error) if sync_method_unsupported(&error) => {
2199            tracing::debug!(
2200                session_id = %target.session_id,
2201                profile_id = %target.profile_id,
2202                "worker predates skills sync; skipping until the target is re-provisioned"
2203            );
2204            return Ok(false);
2205        }
2206        Err(error) => return Err(error),
2207    };
2208    if session == canonical_state {
2209        return Ok(false);
2210    }
2211    let installed = client.install_skills(&canonical.encode()).await?;
2212    if installed != canonical_state {
2213        bail!(
2214            "session {} skills fingerprint {} does not match the canonical {} after install",
2215            target.session_id,
2216            installed.fingerprint,
2217            canonical_state.fingerprint
2218        );
2219    }
2220    Ok(true)
2221}
2222
2223fn sync_method_unsupported(error: &anyhow::Error) -> bool {
2224    error
2225        .downcast_ref::<RelayRejected>()
2226        .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231    use super::*;
2232    use mj_core::relay::RelayObservation;
2233    use mj_worker::relay::DurableRelay;
2234    const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
2235
2236    #[test]
2237    fn relay_decoder_preserves_explicit_desynchronization() {
2238        let response = RelayResponseEnvelope {
2239            request_id: "relay-1".into(),
2240            protocol_version: RELAY_PROTOCOL_VERSION,
2241            body: RelayResponseBody::Error {
2242                error: RelayProtocolError {
2243                    code: RelayErrorCode::Desynchronized,
2244                    message: "journal gap".into(),
2245                    retryable: false,
2246                    detail: None,
2247                },
2248            },
2249        };
2250        let encoded = serde_json::to_string(&response).unwrap();
2251        let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2252        assert!(
2253            error
2254                .downcast_ref::<RelayRejected>()
2255                .is_some_and(RelayRejected::is_desynchronized)
2256        );
2257    }
2258
2259    #[test]
2260    fn relay_decoder_rejects_crossed_request_ids() {
2261        let response = RelayResponseEnvelope {
2262            request_id: "other".into(),
2263            protocol_version: RELAY_PROTOCOL_VERSION,
2264            body: RelayResponseBody::Ok {
2265                payload: RelayResponsePayload::Acknowledged {
2266                    through_ordinal: 4,
2267                    through_digest: "a".repeat(64),
2268                },
2269            },
2270        };
2271        let encoded = serde_json::to_string(&response).unwrap();
2272        assert!(
2273            decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
2274                .unwrap_err()
2275                .to_string()
2276                .contains("ID mismatch")
2277        );
2278    }
2279
2280    #[test]
2281    fn command_spec_preserves_argv_boundaries() {
2282        let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
2283        assert_eq!(spec.program, "ssh");
2284        assert_eq!(spec.args.len(), 2);
2285        assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
2286    }
2287
2288    #[test]
2289    fn relay_protocol_version_range_contains_current_version() {
2290        assert_eq!(
2291            RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
2292            Some(RELAY_PROTOCOL_VERSION)
2293        );
2294        assert_eq!(
2295            RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
2296            Some(1)
2297        );
2298    }
2299
2300    /// A host at its `MaxStartups` ceiling drops the surplus connection before
2301    /// authentication. That says nothing about the worker, so connect retries
2302    /// instead of reporting a dead relay and triggering worker recovery.
2303    #[cfg(unix)]
2304    #[tokio::test]
2305    async fn a_relay_proxy_refused_by_sshd_is_retried_rather_than_reported_dead() {
2306        mj_core::targets::set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2307        let directory = tempfile::tempdir().expect("temp dir");
2308        let counter = directory.path().join("attempts");
2309        let script = format!(
2310            r#"
2311count=$(cat {counter} 2>/dev/null || echo 0)
2312echo $((count + 1)) > {counter}
2313if [ "$count" -eq 0 ]; then
2314  echo 'kex_exchange_identification: read: Connection reset by peer' >&2
2315  exit 255
2316fi
2317IFS= read -r hello
2318id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2319printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{{"negotiated":1,"relay_version":"retry-fixture","session_id":"{session}"}}}}}}\n' "$id"
2320sh -c 'while :; do sleep 30; done'
2321"#,
2322            counter = counter.display(),
2323            session = SESSION_ID
2324        );
2325        let spec = CommandSpec::new("sh", ["-c".to_owned(), script])
2326            .ssh_destination("build@10.0.0.1")
2327            .purpose("refused relay fixture");
2328
2329        let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(10))
2330            .await
2331            .expect("a refused connection must be retried, not reported as a dead relay");
2332
2333        assert_eq!(client.relay_version(), "retry-fixture");
2334        assert_eq!(
2335            std::fs::read_to_string(&counter)
2336                .expect("the fixture records its attempts")
2337                .trim(),
2338            "2"
2339        );
2340        mj_core::targets::set_ssh_retry_backoff_for_test(None);
2341    }
2342
2343    /// A proxy that fails for its own reasons is reported on the first
2344    /// attempt, keeping the existing hello error and its stderr tail.
2345    #[cfg(unix)]
2346    #[tokio::test]
2347    async fn a_relay_proxy_that_fails_for_another_reason_is_not_retried() {
2348        mj_core::targets::set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2349        let directory = tempfile::tempdir().expect("temp dir");
2350        let counter = directory.path().join("attempts");
2351        let script = format!(
2352            r#"
2353count=$(cat {counter} 2>/dev/null || echo 0)
2354echo $((count + 1)) > {counter}
2355echo 'worker socket path is too long' >&2
2356exit 1
2357"#,
2358            counter = counter.display()
2359        );
2360        let spec = CommandSpec::new("sh", ["-c".to_owned(), script])
2361            .ssh_destination("build@10.0.0.1")
2362            .purpose("broken relay fixture");
2363
2364        let Err(error) =
2365            RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(10)).await
2366        else {
2367            panic!("a proxy that exits 1 is a real failure");
2368        };
2369
2370        let reported = format!("{error:#}");
2371        assert!(
2372            reported.contains("relay proxy disconnected during hello"),
2373            "unexpected error: {reported}"
2374        );
2375        assert!(
2376            reported.contains("worker socket path is too long"),
2377            "the stderr tail must survive: {reported}"
2378        );
2379        assert_eq!(
2380            std::fs::read_to_string(&counter)
2381                .expect("the fixture records its attempts")
2382                .trim(),
2383            "1"
2384        );
2385        mj_core::targets::set_ssh_retry_backoff_for_test(None);
2386    }
2387
2388    #[cfg(unix)]
2389    #[tokio::test]
2390    async fn controller_accepts_negotiated_protocol_v1() {
2391        let script = format!(
2392            r#"python3 -c '
2393import json, sys
2394session = {session:?}
2395req = json.loads(sys.stdin.readline())
2396assert req["request"]["method"] == "hello"
2397supported = req["request"]["params"]["supported"]
2398assert supported["min"] <= 1 <= supported["max"]
2399print(json.dumps({{
2400    "request_id": req["request_id"],
2401    "protocol_version": 1,
2402    "result": "ok",
2403    "payload": {{
2404        "type": "hello",
2405        "data": {{
2406            "negotiated": 1,
2407            "relay_version": "v1-fixture",
2408            "session_id": session,
2409        }},
2410    }},
2411}}), flush=True)
2412sys.stdin.read()
2413'"#,
2414            session = SESSION_ID
2415        );
2416        let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2417        let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2418            .await
2419            .expect("protocol v1 hello must be accepted");
2420        assert_eq!(client.protocol_version(), 1);
2421        assert_eq!(client.relay_version(), "v1-fixture");
2422    }
2423
2424    /// The build a worker reports is what decides whether it is replaced, so a
2425    /// controller has to read it from hello - and read a worker that reports
2426    /// none as exactly that, rather than failing the handshake.
2427    #[cfg(unix)]
2428    #[tokio::test]
2429    async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2430        let hello = |build: Option<&str>| {
2431            let data = match build {
2432                Some(build) => format!(
2433                    r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2434                ),
2435                None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2436                    .to_owned(),
2437            };
2438            format!(
2439                r#"
2440IFS= read -r hello
2441id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2442printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2443' "$id" "$1"
2444sh -c 'while :; do sleep 30; done'
2445"#
2446            )
2447        };
2448        for reported in [None, Some("a".repeat(64).as_str())] {
2449            let spec = CommandSpec::new(
2450                "sh",
2451                [
2452                    "-c".to_owned(),
2453                    hello(reported),
2454                    "hel-relay-build-fixture".to_owned(),
2455                    SESSION_ID.to_owned(),
2456                ],
2457            )
2458            .purpose("relay worker build fixture");
2459            let client =
2460                RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2461                    .await
2462                    .expect("hello must be accepted with and without a worker build");
2463            assert_eq!(client.worker_build(), reported);
2464        }
2465    }
2466
2467    #[cfg(unix)]
2468    #[tokio::test]
2469    async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2470        let directory = tempfile::tempdir().unwrap();
2471        let eof = directory.path().join("proxy-saw-eof");
2472        let script = r#"
2473IFS= read -r hello
2474id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2475printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2476if IFS= read -r _; then exit 9; fi
2477: > "$2"
2478"#;
2479        let spec = CommandSpec::new(
2480            "sh",
2481            [
2482                "-c".to_owned(),
2483                script.to_owned(),
2484                "hel-relay-eof-fixture".to_owned(),
2485                SESSION_ID.to_owned(),
2486                eof.to_string_lossy().into_owned(),
2487            ],
2488        )
2489        .purpose("relay proxy EOF fixture");
2490        let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2491            .await
2492            .unwrap();
2493
2494        drop(client);
2495        tokio::time::timeout(Duration::from_secs(2), async {
2496            while !eof.exists() {
2497                tokio::time::sleep(Duration::from_millis(10)).await;
2498            }
2499        })
2500        .await
2501        .expect("proxy launcher was killed before it observed stdin EOF");
2502    }
2503
2504    #[cfg(unix)]
2505    #[tokio::test]
2506    async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2507        let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2508        let script = format!(
2509            r#"python3 -c '
2510import json, sys
2511session = {session:?}
2512req = json.loads(sys.stdin.readline())
2513print(json.dumps({{
2514    "request_id": req["request_id"],
2515    "protocol_version": {future_protocol},
2516    "result": "ok",
2517    "payload": {{
2518        "type": "hello",
2519        "data": {{
2520            "negotiated": {future_protocol},
2521            "relay_version": "future",
2522            "session_id": session,
2523        }},
2524    }},
2525}}), flush=True)
2526sys.stdin.read()
2527'"#,
2528            session = SESSION_ID,
2529            future_protocol = future_protocol,
2530        );
2531        let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2532        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2533            .await
2534            .err()
2535            .expect("a future protocol hello must be rejected");
2536        assert!(
2537            error.to_string().contains(&format!(
2538                "negotiated unsupported protocol {future_protocol}"
2539            )),
2540            "{error:#}"
2541        );
2542        // The transport carried the answer perfectly well; restarting the
2543        // worker cannot make it speak a protocol it does not implement.
2544        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2545    }
2546
2547    /// A proxy that exits without answering is the ordinary shape of a dead
2548    /// worker. Recovery hangs on this being typed rather than read.
2549    #[cfg(unix)]
2550    #[tokio::test]
2551    async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2552        let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2553
2554        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2555            .await
2556            .err()
2557            .expect("a proxy that exits cannot complete hello");
2558
2559        assert!(RelayTransportDead::marks(&error), "{error:#}");
2560        assert!(RelayTransportDead::marks_failed_handshake(&error));
2561    }
2562
2563    /// The proxy explains failures the controller cannot observe itself, such
2564    /// as a worker socket path longer than `sun_path`. Logging that line is
2565    /// not enough: the error the caller reports must carry it too.
2566    #[cfg(unix)]
2567    #[tokio::test]
2568    async fn a_hello_failure_carries_the_proxy_stderr_tail() {
2569        const COMPLAINT: &str =
2570            "connect worker socket /x/control.sock: path must be shorter than SUN_LEN";
2571        let spec = CommandSpec::new("sh", ["-c", &format!("echo '{COMPLAINT}' >&2; exit 1")])
2572            .purpose("complaining relay proxy");
2573
2574        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2575            .await
2576            .err()
2577            .expect("a proxy that exits cannot complete hello");
2578
2579        assert!(format!("{error:#}").contains(COMPLAINT), "{error:#}");
2580        // Added context must not hide the classification recovery reads.
2581        assert!(RelayTransportDead::marks(&error), "{error:#}");
2582        assert!(RelayTransportDead::marks_failed_handshake(&error));
2583    }
2584
2585    #[cfg(unix)]
2586    #[tokio::test]
2587    async fn silent_proxy_handshake_has_a_bounded_deadline() {
2588        let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2589        let started = std::time::Instant::now();
2590
2591        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2592            .await
2593            .err()
2594            .expect("silent relay must time out");
2595
2596        assert!(error.to_string().contains("relay hello timed out"));
2597        // The launcher is still alive. A loaded target can look exactly like
2598        // this while starting its proxy, so worker recovery must not restart
2599        // the native session merely because the deadline elapsed.
2600        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2601        assert!(!RelayTransportDead::marks_failed_handshake(&error));
2602        assert!(started.elapsed() < Duration::from_secs(2));
2603    }
2604
2605    /// A relay that answers `hello` at once and then stalls, replying to the
2606    /// next request long after any controller deadline. `$1` is the session id.
2607    #[cfg(unix)]
2608    const STALLING_RELAY: &str = r#"
2609IFS= read -r hello
2610id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2611printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2612IFS= read -r stalled
2613id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2614sleep 5
2615printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2616cat > /dev/null
2617"#;
2618
2619    #[cfg(unix)]
2620    #[tokio::test]
2621    async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2622        let spec = CommandSpec::new(
2623            "sh",
2624            ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2625        )
2626        .purpose("stalling relay fixture");
2627        let mut client =
2628            RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2629                .await
2630                .expect("the fixture answers hello immediately");
2631
2632        let timed_out = client
2633            .status()
2634            .await
2635            .expect_err("the stalled status call must time out");
2636        assert!(
2637            format!("{timed_out:#}").contains("relay status timed out"),
2638            "{timed_out:#}"
2639        );
2640        // A busy worker that misses one deadline is not a dead transport: it
2641        // answered the handshake, and killing it would be worse than waiting.
2642        assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2643
2644        // The abandoned reply is still in flight. A later call must not read it
2645        // as its own response, so it fails at once with the real cause. The
2646        // normal request deadline is long enough that without this the
2647        // controller would block on someone else's reply.
2648        let started = std::time::Instant::now();
2649        let subsequent = client
2650            .status()
2651            .await
2652            .expect_err("a call on an abandoned connection must fail");
2653        let elapsed = started.elapsed();
2654        assert!(
2655            format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2656            "{subsequent:#}"
2657        );
2658        assert!(
2659            elapsed < Duration::from_millis(250),
2660            "an abandoned connection must fail fast, took {elapsed:?}"
2661        );
2662
2663        let repeated = client
2664            .status()
2665            .await
2666            .expect_err("the connection stays abandoned");
2667        assert!(
2668            format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2669            "{repeated:#}"
2670        );
2671    }
2672
2673    #[test]
2674    fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2675        // Workers that predate skills sync answer the unknown method with an
2676        // `InvalidRequest` rejection, and so does a current worker's structured
2677        // unsupported-method response. Both must skip the session quietly.
2678        let response = mj_core::relay::unsupported_relay_method_response(
2679            "relay-1".into(),
2680            RELAY_PROTOCOL_VERSION,
2681            "skills_state".into(),
2682        );
2683        let encoded = serde_json::to_string(&response).unwrap();
2684        let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2685        assert!(sync_method_unsupported(&error), "{error:#}");
2686    }
2687
2688    #[tokio::test]
2689    async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2690        let profile = tempfile::tempdir().unwrap();
2691        let mut coordinator = CredentialSyncCoordinator::spawn();
2692        coordinator.handle().set_targets(vec![CredentialSyncTarget {
2693            session_id: SESSION_ID.into(),
2694            profile_id: "work".into(),
2695            harness: mj_core::config::HarnessKind::Codex,
2696            profile_home: profile.path().to_path_buf(),
2697            sync_github_token: false,
2698            spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2699        }]);
2700
2701        let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2702            .await
2703            .expect("target publication must not wait for the 60-second periodic tick")
2704            .expect("credential coordinator stopped");
2705        assert_eq!(result.profile_id, "work");
2706        assert_eq!(result.outcomes.len(), 1);
2707        assert!(result.outcomes[0].outcome.is_err());
2708    }
2709
2710    #[tokio::test]
2711    async fn response_frame_limit_is_enforced_before_newline() {
2712        let (mut writer, reader) = tokio::io::duplex(32);
2713        let write = tokio::spawn(async move {
2714            writer.write_all(b"123456789\n").await.unwrap();
2715        });
2716        let mut reader = BufReader::new(reader);
2717
2718        let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2719            .await
2720            .unwrap_err();
2721
2722        write.await.unwrap();
2723        assert!(error.to_string().contains("frame is too large"));
2724        // An oversized frame is a protocol violation, not a dead transport:
2725        // the same worker would send the same frame after a restart.
2726        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2727    }
2728
2729    #[tokio::test]
2730    async fn a_half_written_response_frame_reports_a_dead_transport() {
2731        let (mut writer, reader) = tokio::io::duplex(32);
2732        writer.write_all(b"{\"partial\":").await.unwrap();
2733        drop(writer);
2734        let mut reader = BufReader::new(reader);
2735
2736        let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2737            .await
2738            .unwrap_err();
2739
2740        assert!(RelayTransportDead::marks(&error), "{error:#}");
2741        assert!(!RelayTransportDead::marks_failed_handshake(&error));
2742    }
2743
2744    #[test]
2745    fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2746        let temp = tempfile::tempdir().unwrap();
2747        let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2748        for message in ["one", "two", "arrived concurrently"] {
2749            relay
2750                .record_observation(RelayObservation::Warning {
2751                    message: message.into(),
2752                })
2753                .unwrap();
2754        }
2755        let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2756        let previous = RelayCursor {
2757            ordinal: all[0].ordinal,
2758            digest: all[0].digest.clone(),
2759        };
2760        let frontier = RelayCursor {
2761            ordinal: all[1].ordinal,
2762            digest: all[1].digest.clone(),
2763        };
2764        let page = RelayAttachment {
2765            state: relay.operational_state(),
2766            events: all[1..].to_vec(),
2767            through_ordinal: all[2].ordinal,
2768            through_digest: all[2].digest.clone(),
2769        };
2770        let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2771        assert_eq!(clipped.through_ordinal, frontier.ordinal);
2772        assert_eq!(clipped.through_digest, frontier.digest);
2773        assert_eq!(clipped.events.len(), 1);
2774        assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2775    }
2776}