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