Skip to main content

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