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    /// Copy a verified controller blob to this session before admitting its reference.
685    pub async fn ensure_attachment(
686        &mut self,
687        reference: &hel::hel_attachment::AttachmentRef,
688    ) -> Result<()> {
689        anyhow::ensure!(
690            self.protocol_version >= 8,
691            "photo attachments require an updated worker (protocol 8); upgrade the worker and retry"
692        );
693        match self
694            .call(RelayRequest::AttachmentPresent {
695                reference: reference.clone(),
696            })
697            .await?
698        {
699            RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
700            RelayResponsePayload::AttachmentPresent { present: false } => {}
701            _ => bail!("unexpected image presence response"),
702        }
703        let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
704        let reference_copy = reference.clone();
705        let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
706            .await
707            .context("image loading task failed")??;
708        match self
709            .call(RelayRequest::InstallAttachment {
710                reference: reference.clone(),
711                data: BASE64.encode(bytes),
712            })
713            .await?
714        {
715            RelayResponsePayload::AttachmentInstalled => Ok(()),
716            _ => bail!("unexpected image upload response"),
717        }
718    }
719
720    /// Recover the local copy needed for queue editing and resubmission.
721    pub async fn cache_attachment(
722        &mut self,
723        reference: &hel::hel_attachment::AttachmentRef,
724    ) -> Result<()> {
725        let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
726        let local = store.clone();
727        let reference_copy = reference.clone();
728        if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
729            .await
730            .context("image lookup task failed")??
731        {
732            return Ok(());
733        }
734        let RelayResponsePayload::AttachmentData { data } = self
735            .call(RelayRequest::ReadAttachment {
736                reference: reference.clone(),
737            })
738            .await?
739        else {
740            bail!("unexpected image download response")
741        };
742        let reference = reference.clone();
743        tokio::task::spawn_blocking(move || {
744            anyhow::ensure!(
745                data.len() <= hel::hel_attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
746                "image download is too large"
747            );
748            store.install(&reference, &BASE64.decode(data)?)
749        })
750        .await
751        .context("image caching task failed")?
752    }
753
754    pub async fn submit(
755        &mut self,
756        command_id: impl Into<String>,
757        command: RelayCommand,
758    ) -> Result<u64> {
759        let command_id = command_id.into();
760        if let RelayCommand::Prompt { prompt } = &command {
761            for reference in hel::hel_attachment::references(prompt)? {
762                self.ensure_attachment(&reference).await?;
763            }
764        }
765        match self
766            .call(RelayRequest::Submit {
767                command_id: command_id.clone(),
768                command,
769            })
770            .await?
771        {
772            RelayResponsePayload::Accepted {
773                command_id: accepted_id,
774                ordinal,
775            } if accepted_id == command_id => Ok(ordinal),
776            RelayResponsePayload::Accepted {
777                command_id: accepted_id,
778                ..
779            } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
780            _ => bail!("relay returned an unexpected command response"),
781        }
782    }
783
784    /// Start the second-opinion reviewer beside this session, or report the
785    /// running one when it already matches `config`.
786    ///
787    /// The reviewer's profile must already be staged on the target. Starting
788    /// can take as long as opening any harness session, so this uses the
789    /// handshake deadline rather than the bookkeeping one.
790    pub async fn start_reviewer(
791        &mut self,
792        role: Option<&str>,
793        config: ReviewerLaunchConfig,
794    ) -> Result<StartedReviewer> {
795        let request = self.reviewer_request(
796            role,
797            ReviewerRequest::Start {
798                config: Box::new(config),
799            },
800        )?;
801        match self
802            .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
803            .await?
804        {
805            RelayResponsePayload::ReviewerStarted {
806                native_session_id,
807                config_options,
808                reused,
809                state,
810            } => Ok(StartedReviewer {
811                native_session_id,
812                config_options,
813                reused,
814                state: *state,
815            }),
816            _ => bail!("relay returned an unexpected reviewer start response"),
817        }
818    }
819
820    /// Replay the reviewer's journal from a cursor, exactly as [`Self::attach`]
821    /// does for the primary.
822    pub async fn attach_reviewer(
823        &mut self,
824        role: Option<&str>,
825        after_ordinal: u64,
826        after_digest: impl Into<String>,
827    ) -> Result<RelayAttachment> {
828        let after_digest = after_digest.into();
829        let request = self.reviewer_request(
830            role,
831            ReviewerRequest::Attach {
832                after_ordinal,
833                after_digest: after_digest.clone(),
834            },
835        )?;
836        let payload = self
837            .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
838            .await?;
839        let RelayResponsePayload::Attached {
840            state,
841            events,
842            through_ordinal,
843            through_digest,
844        } = payload
845        else {
846            bail!("relay returned an unexpected reviewer attach response");
847        };
848        // The reviewer's journal is verified the same way the primary's is: a
849        // sidecar's history is not exempt from the chain check.
850        let mut cursor = RelayCursor {
851            ordinal: after_ordinal,
852            digest: after_digest,
853        };
854        for event in &events {
855            validate_relay_event(cursor.ordinal, &cursor.digest, event)
856                .context("verify reviewer attachment event chain")?;
857            cursor.ordinal = event.ordinal;
858            cursor.digest.clone_from(&event.digest);
859        }
860        if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
861            bail!("reviewer attachment frontier does not match its event chain");
862        }
863        Ok(RelayAttachment {
864            state,
865            events,
866            through_ordinal,
867            through_digest,
868        })
869    }
870
871    /// Advance the reviewer's acknowledged frontier so its journal can be
872    /// pruned once the controller has the events durably.
873    pub async fn acknowledge_reviewer(
874        &mut self,
875        role: Option<&str>,
876        through_ordinal: u64,
877        through_digest: impl Into<String>,
878    ) -> Result<RelayCursor> {
879        let request = self.reviewer_request(
880            role,
881            ReviewerRequest::Acknowledge {
882                through_ordinal,
883                through_digest: through_digest.into(),
884            },
885        )?;
886        match self
887            .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
888            .await?
889        {
890            RelayResponsePayload::Acknowledged {
891                through_ordinal,
892                through_digest,
893            } => Ok(RelayCursor {
894                ordinal: through_ordinal,
895                digest: through_digest,
896            }),
897            _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
898        }
899    }
900
901    /// Queue one command on the reviewer's own relay.
902    pub async fn submit_to_reviewer(
903        &mut self,
904        role: Option<&str>,
905        command_id: impl Into<String>,
906        command: RelayCommand,
907    ) -> Result<u64> {
908        let command_id = command_id.into();
909        let request = self.reviewer_request(
910            role,
911            ReviewerRequest::Submit {
912                command_id: command_id.clone(),
913                command,
914            },
915        )?;
916        match self.call(request).await? {
917            RelayResponsePayload::Accepted {
918                command_id: accepted_id,
919                ordinal,
920            } if accepted_id == command_id => Ok(ordinal),
921            RelayResponsePayload::Accepted {
922                command_id: accepted_id,
923                ..
924            } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
925            _ => bail!("relay returned an unexpected reviewer command response"),
926        }
927    }
928
929    pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
930        let request = self.reviewer_request(role, ReviewerRequest::Status)?;
931        match self.call(request).await? {
932            RelayResponsePayload::Status(status) => Ok(status),
933            _ => bail!("relay returned an unexpected reviewer status response"),
934        }
935    }
936
937    /// Answer a form the reviewer's harness is waiting on.
938    pub async fn respond_to_reviewer(
939        &mut self,
940        role: Option<&str>,
941        elicitation_id: String,
942        response: ElicitationResponse,
943    ) -> Result<()> {
944        let request = self.reviewer_request(
945            role,
946            ReviewerRequest::RespondElicitation {
947                elicitation_id: elicitation_id.clone(),
948                response,
949            },
950        )?;
951        match self.call(request).await? {
952            RelayResponsePayload::ElicitationResolved {
953                elicitation_id: resolved,
954            } if resolved == elicitation_id => Ok(()),
955            RelayResponsePayload::ElicitationResolved {
956                elicitation_id: resolved,
957            } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
958            _ => bail!("relay returned an unexpected reviewer elicitation response"),
959        }
960    }
961
962    /// Cancel any reviewer turn in flight and stop its process group, keeping
963    /// its staged profile, native session and journal for the next review.
964    pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
965        let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
966        match self
967            .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
968            .await?
969        {
970            RelayResponsePayload::ReviewerPaused => Ok(()),
971            _ => bail!("relay returned an unexpected reviewer pause response"),
972        }
973    }
974
975    /// Report what every workspace repository changed since the review
976    /// baselines the controller holds.
977    pub async fn capture_review_delta(
978        &mut self,
979        role: Option<&str>,
980        baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
981    ) -> Result<Vec<hel::hel_worker::RepoDelta>> {
982        let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
983        match self
984            .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
985            .await?
986        {
987            RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
988            _ => bail!("relay returned an unexpected review capture response"),
989        }
990    }
991
992    /// Record the trees a completed review reviewed through, so the next
993    /// review starts from them.
994    pub async fn advance_review_baseline(
995        &mut self,
996        role: Option<&str>,
997        trees: std::collections::BTreeMap<std::path::PathBuf, String>,
998    ) -> Result<()> {
999        let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
1000        match self
1001            .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1002            .await?
1003        {
1004            RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
1005            _ => bail!("relay returned an unexpected review baseline response"),
1006        }
1007    }
1008
1009    /// Run Bifrost's semantic diff analysis over the captured trees. It can
1010    /// take minutes on a large changeset, so it carries its own budget.
1011    pub async fn analyze_review_delta(
1012        &mut self,
1013        role: Option<&str>,
1014        repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
1015    ) -> Result<String> {
1016        let request =
1017            self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
1018        match self
1019            .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
1020            .await?
1021        {
1022            RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
1023            _ => bail!("relay returned an unexpected review analysis response"),
1024        }
1025    }
1026
1027    /// Collect the specialist lanes the review supervisor asked for since the
1028    /// last call.
1029    pub async fn take_lane_dispatches(
1030        &mut self,
1031    ) -> Result<Vec<hel::hel_review::lanes::ReviewSubagentRequest>> {
1032        let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
1033        match self.call(request).await? {
1034            RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
1035            _ => bail!("relay returned an unexpected lane dispatch response"),
1036        }
1037    }
1038
1039    /// Wraps a reviewer action, refusing it on a worker too old to know what a
1040    /// reviewer is rather than sending a method it would reject as unknown.
1041    fn reviewer_request(
1042        &self,
1043        role: Option<&str>,
1044        request: ReviewerRequest,
1045    ) -> Result<RelayRequest> {
1046        let request = RelayRequest::Reviewer {
1047            role: role.map(str::to_owned),
1048            request,
1049        };
1050        if !request.supported_at(self.protocol_version) {
1051            bail!(
1052                "a second opinion requires relay protocol {}; this session negotiated {}",
1053                request.minimum_protocol(),
1054                self.protocol_version
1055            );
1056        }
1057        Ok(request)
1058    }
1059
1060    /// Answer an ACP form over the live relay connection. User-entered content
1061    /// is intentionally excluded from the relay's durable command path.
1062    pub async fn respond_elicitation(
1063        &mut self,
1064        elicitation_id: String,
1065        response: ElicitationResponse,
1066    ) -> Result<()> {
1067        let request = RelayRequest::RespondElicitation {
1068            elicitation_id: elicitation_id.clone(),
1069            response,
1070        };
1071        if !request.supported_at(self.protocol_version) {
1072            bail!(
1073                "elicitation responses require relay protocol {}; this session negotiated {}",
1074                request.minimum_protocol(),
1075                self.protocol_version
1076            );
1077        }
1078        match self.call(request).await? {
1079            RelayResponsePayload::ElicitationResolved {
1080                elicitation_id: resolved,
1081            } if resolved == elicitation_id => Ok(()),
1082            RelayResponsePayload::ElicitationResolved {
1083                elicitation_id: resolved,
1084            } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1085            _ => bail!("relay returned an unexpected elicitation response"),
1086        }
1087    }
1088
1089    pub async fn detach(mut self) -> Result<()> {
1090        self.input
1091            .take()
1092            .expect("connected relay owns proxy stdin")
1093            .shutdown()
1094            .await
1095            .context("close relay proxy stdin")?;
1096        let mut child = self.child.take().expect("connected relay owns proxy child");
1097        match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1098            Ok(status) => {
1099                status.context("wait for relay proxy")?;
1100            }
1101            Err(_) => {
1102                if let Err(error) = child.start_kill().context("stop relay proxy") {
1103                    tracing::warn!(
1104                        session_id = %self.session_id,
1105                        operation = "detach",
1106                        %error,
1107                        "could not stop relay proxy after detach timeout"
1108                    );
1109                    return Err(error);
1110                }
1111                if let Err(error) = child.wait().await {
1112                    tracing::warn!(
1113                        session_id = %self.session_id,
1114                        operation = "detach",
1115                        %error,
1116                        "could not reap relay proxy after stopping it"
1117                    );
1118                }
1119            }
1120        }
1121        Ok(())
1122    }
1123
1124    async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1125        self.call_with_timeout(request, self.request_timeout).await
1126    }
1127
1128    async fn call_with_timeout(
1129        &mut self,
1130        request: RelayRequest,
1131        timeout: Duration,
1132    ) -> Result<RelayResponsePayload> {
1133        let operation = request.method_name();
1134        let request_id = self.request_id();
1135        let envelope = RelayRequestEnvelope {
1136            request_id: request_id.clone(),
1137            protocol_version: self.protocol_version,
1138            request,
1139        };
1140        let line = match self
1141            .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1142            .await
1143        {
1144            Ok(line) => line,
1145            Err(error) => {
1146                log_relay_client_failure(self, operation, &request_id, &error);
1147                return Err(error);
1148            }
1149        };
1150        let result = decode_relay_response(&line, &request_id, self.protocol_version)
1151            .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1152        if let Err(error) = &result {
1153            log_relay_client_failure(self, operation, &request_id, error);
1154        }
1155        result
1156    }
1157
1158    async fn call_hello(
1159        &mut self,
1160        request: RelayRequest,
1161        timeout: Duration,
1162    ) -> Result<RelayResponsePayload> {
1163        let operation = request.method_name();
1164        let request_id = self.request_id();
1165        let envelope = RelayRequestEnvelope {
1166            request_id: request_id.clone(),
1167            protocol_version: RELAY_PROTOCOL_VERSION,
1168            request,
1169        };
1170        let line = match self
1171            .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1172            .await
1173        {
1174            Ok(line) => line,
1175            Err(error) => {
1176                log_relay_client_failure(self, operation, &request_id, &error);
1177                return Err(error);
1178            }
1179        };
1180        let result = decode_relay_hello_response(&line, &request_id);
1181        if let Err(error) = &result {
1182            log_relay_client_failure(self, operation, &request_id, error);
1183        }
1184        result
1185    }
1186
1187    /// Write one request frame and read the reply that belongs to it.
1188    ///
1189    /// The connection is strictly sequential, so giving up on a reply does not
1190    /// cancel it: the relay may still answer, and that answer would be read as
1191    /// the *next* call's response. Timeouts therefore abandon the connection
1192    /// rather than the single call. Every later call fails immediately with the
1193    /// true cause, so callers reconnect deliberately instead of chasing a
1194    /// mismatched response ID. This matters most where a short bookkeeping
1195    /// deadline and a long compaction deadline share one connection.
1196    async fn exchange(
1197        &mut self,
1198        envelope: &RelayRequestEnvelope,
1199        operation: &str,
1200        timeout: Duration,
1201        kind: ExchangeKind,
1202    ) -> Result<String> {
1203        if let Some(reason) = &self.abandoned {
1204            bail!("{reason}");
1205        }
1206        let mut frame = serde_json::to_vec(envelope)?;
1207        if frame.len() > MAX_FRAME_BYTES {
1208            bail!("relay {operation} request frame is too large");
1209        }
1210        frame.push(b'\n');
1211        let session_id = self.session_id.clone();
1212        let exchanged = tokio::time::timeout(timeout, async {
1213            self.input
1214                .as_mut()
1215                .expect("connected relay owns proxy stdin")
1216                .write_all(&frame)
1217                .await
1218                .map_err(|error| RelayTransportDead::from_io(error, kind))
1219                .with_context(|| format!("write relay {operation} request"))?;
1220            self.input
1221                .as_mut()
1222                .expect("connected relay owns proxy stdin")
1223                .flush()
1224                .await
1225                .map_err(|error| RelayTransportDead::from_io(error, kind))
1226                .with_context(|| format!("flush relay {operation} request"))?;
1227            let response = read_bounded_frame(&mut self.output, kind);
1228            tokio::pin!(response);
1229            let response = tokio::select! {
1230                response = &mut response => response,
1231                () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1232                    tracing::warn!(
1233                        %session_id,
1234                        %operation,
1235                        warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1236                        timeout_seconds = timeout.as_secs_f64(),
1237                        "relay operation is still waiting for its response"
1238                    );
1239                    response.await
1240                }
1241            };
1242            response
1243                .with_context(|| format!("read relay {operation} response"))?
1244                .ok_or_else(|| {
1245                    anyhow::Error::new(RelayTransportDead::during_exchange(
1246                        format!("relay proxy disconnected during {operation}"),
1247                        kind,
1248                    ))
1249                })
1250        })
1251        .await;
1252        match exchanged {
1253            Ok(line) => line,
1254            Err(_elapsed) => {
1255                let seconds = timeout.as_secs_f64();
1256                tracing::warn!(
1257                    %session_id,
1258                    %operation,
1259                    timeout_seconds = seconds,
1260                    "relay operation timed out; abandoning its sequential connection"
1261                );
1262                self.abandoned = Some(format!(
1263                    "relay connection abandoned after {operation} timed out after {seconds} seconds"
1264                ));
1265                let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1266                Err(anyhow!(timed_out))
1267            }
1268        }
1269    }
1270
1271    fn request_id(&mut self) -> String {
1272        let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1273        self.next_request = self.next_request.wrapping_add(1);
1274        id
1275    }
1276}
1277
1278/// Keep transport, protocol, and explicit relay rejections visible at the
1279/// point where a request fails. Callers often turn these into a user-facing
1280/// string or a retry, which otherwise loses the operation and request ID that
1281/// make concurrent session failures diagnosable.
1282fn log_relay_client_failure(
1283    client: &RelayClient,
1284    operation: &str,
1285    request_id: &str,
1286    error: &anyhow::Error,
1287) {
1288    let rejection = error.chain().find_map(|cause| {
1289        cause
1290            .downcast_ref::<RelayRejected>()
1291            .map(|rejected| &rejected.0)
1292    });
1293    let transport_dead = RelayTransportDead::marks(error);
1294    match rejection {
1295        Some(rejection) => tracing::warn!(
1296            session_id = %client.session_id,
1297            relay_version = %client.relay_version,
1298            %operation,
1299            %request_id,
1300            relay_error_code = ?rejection.code,
1301            relay_retryable = rejection.retryable,
1302            transport_dead,
1303            error = %error,
1304            "relay request rejected"
1305        ),
1306        None => tracing::warn!(
1307            session_id = %client.session_id,
1308            relay_version = %client.relay_version,
1309            %operation,
1310            %request_id,
1311            transport_dead,
1312            error = %error,
1313            "relay request failed"
1314        ),
1315    }
1316}
1317
1318impl Drop for RelayClient {
1319    fn drop(&mut self) {
1320        // Async owners call `detach` so EOF has a bounded chance to propagate
1321        // through Podman or SSH before the launcher is stopped. Drop is the
1322        // shutdown-safe fallback: it may run while Tokio's drivers are already
1323        // gone, so its bounded reaper cannot use runtime work or Tokio timers.
1324        drop(self.input.take());
1325        let Some(child) = self.child.take() else {
1326            return;
1327        };
1328        let session_id = self.session_id.clone();
1329        if let Err(error) = std::thread::Builder::new()
1330            .name("hel-relay-reaper".into())
1331            .spawn(move || reap_dropped_relay_proxy(child, session_id))
1332        {
1333            tracing::warn!(
1334                session_id = %self.session_id,
1335                %error,
1336                "could not start dropped relay proxy reaper"
1337            );
1338        }
1339    }
1340}
1341
1342/// Let EOF traverse a proxy launcher, then stop and reap it without relying on
1343/// an async runtime that may already be shutting down.
1344fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1345    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1346    loop {
1347        match child.try_wait() {
1348            Ok(Some(status)) => {
1349                if !status.success() {
1350                    tracing::warn!(
1351                        %session_id,
1352                        %status,
1353                        "dropped relay proxy exited unsuccessfully"
1354                    );
1355                }
1356                return;
1357            }
1358            Ok(None) if Instant::now() < deadline => {
1359                std::thread::sleep(RELAY_PROXY_REAP_POLL);
1360            }
1361            Ok(None) => break,
1362            Err(error) => {
1363                tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1364                return;
1365            }
1366        }
1367    }
1368
1369    if let Err(error) = child.start_kill()
1370        && error.kind() != std::io::ErrorKind::NotFound
1371    {
1372        tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1373        return;
1374    }
1375    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1376    loop {
1377        match child.try_wait() {
1378            Ok(Some(_)) => return,
1379            Ok(None) if Instant::now() < deadline => {
1380                std::thread::sleep(RELAY_PROXY_REAP_POLL);
1381            }
1382            Ok(None) => {
1383                tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1384                return;
1385            }
1386            Err(error) => {
1387                tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1388                return;
1389            }
1390        }
1391    }
1392}
1393
1394fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1395    match payload {
1396        RelayResponsePayload::CredentialState {
1397            present,
1398            fingerprint,
1399            freshness_epoch_ms,
1400        } => Ok(CredentialSnapshot {
1401            present,
1402            fingerprint,
1403            freshness_epoch_ms,
1404        }),
1405        _ => bail!("relay returned an unexpected credential state response"),
1406    }
1407}
1408
1409fn skills_sync_state(payload: RelayResponsePayload) -> Result<hel::hel_skills::SkillsSyncState> {
1410    match payload {
1411        RelayResponsePayload::SkillsState {
1412            present,
1413            fingerprint,
1414        } => Ok(hel::hel_skills::SkillsSyncState {
1415            present,
1416            fingerprint,
1417        }),
1418        _ => bail!("relay returned an unexpected skills state response"),
1419    }
1420}
1421
1422fn github_token_snapshot(
1423    payload: RelayResponsePayload,
1424) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
1425    match payload {
1426        RelayResponsePayload::GithubTokenState {
1427            present,
1428            fingerprint,
1429        } => Ok(hel::hel_credentials::GithubTokenSnapshot {
1430            present,
1431            fingerprint,
1432        }),
1433        _ => bail!("relay returned an unexpected GitHub token state response"),
1434    }
1435}
1436
1437async fn read_bounded_frame(
1438    reader: &mut (impl AsyncBufRead + Unpin),
1439    kind: ExchangeKind,
1440) -> Result<Option<String>> {
1441    read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1442}
1443
1444async fn read_bounded_frame_with_limit(
1445    reader: &mut (impl AsyncBufRead + Unpin),
1446    maximum_bytes: usize,
1447    kind: ExchangeKind,
1448) -> Result<Option<String>> {
1449    let mut frame = Vec::new();
1450    loop {
1451        // A failed read and a half-written frame are transport deaths; the
1452        // limit and encoding failures below are protocol violations that a
1453        // worker restart would not fix, so only these two carry the marker.
1454        let available = reader
1455            .fill_buf()
1456            .await
1457            .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1458        if available.is_empty() {
1459            if frame.is_empty() {
1460                return Ok(None);
1461            }
1462            return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1463                "relay proxy disconnected in the middle of a response frame",
1464                kind,
1465            )));
1466        }
1467        let newline = available.iter().position(|byte| *byte == b'\n');
1468        let consumed = newline.map_or(available.len(), |position| position + 1);
1469        let payload = newline.map_or(available, |position| &available[..position]);
1470        if frame.len().saturating_add(payload.len()) > maximum_bytes {
1471            bail!("relay response frame is too large");
1472        }
1473        frame.extend_from_slice(payload);
1474        reader.consume(consumed);
1475        if newline.is_some() {
1476            if frame.last() == Some(&b'\r') {
1477                frame.pop();
1478            }
1479            return String::from_utf8(frame)
1480                .context("relay response is not UTF-8")
1481                .map(Some);
1482        }
1483    }
1484}
1485
1486fn clip_catch_up_page(
1487    page: RelayAttachment,
1488    previous: &RelayCursor,
1489    frontier: &RelayCursor,
1490) -> Result<RelayEventPage> {
1491    if previous.ordinal > frontier.ordinal {
1492        bail!("relay catch-up starts beyond its fixed frontier");
1493    }
1494    if previous.ordinal == frontier.ordinal {
1495        if previous != frontier {
1496            bail!("relay catch-up cursor digest differs from its fixed frontier");
1497        }
1498        if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1499            bail!("relay attachment advanced beyond its advertised frontier");
1500        }
1501        return Ok(RelayEventPage {
1502            events: Vec::new(),
1503            through_ordinal: previous.ordinal,
1504            through_digest: previous.digest.clone(),
1505        });
1506    }
1507    if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1508        bail!("relay catch-up page did not advance");
1509    }
1510    if page.through_ordinal <= frontier.ordinal {
1511        let through = RelayCursor {
1512            ordinal: page.through_ordinal,
1513            digest: page.through_digest.clone(),
1514        };
1515        if through.ordinal == frontier.ordinal && through != *frontier {
1516            bail!("relay catch-up page digest differs from its fixed frontier");
1517        }
1518        return Ok(RelayEventPage {
1519            events: page.events,
1520            through_ordinal: through.ordinal,
1521            through_digest: through.digest,
1522        });
1523    }
1524
1525    let events = page
1526        .events
1527        .into_iter()
1528        .take_while(|event| event.ordinal <= frontier.ordinal)
1529        .collect::<Vec<_>>();
1530    let reached = events
1531        .last()
1532        .map(|event| RelayCursor {
1533            ordinal: event.ordinal,
1534            digest: event.digest.clone(),
1535        })
1536        .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1537    if reached != *frontier {
1538        bail!("relay catch-up page does not contain its fixed frontier");
1539    }
1540    Ok(RelayEventPage {
1541        events,
1542        through_ordinal: reached.ordinal,
1543        through_digest: reached.digest,
1544    })
1545}
1546
1547fn decode_relay_response(
1548    line: &str,
1549    request_id: &str,
1550    protocol: u32,
1551) -> Result<RelayResponsePayload> {
1552    let response: RelayResponseEnvelope =
1553        serde_json::from_str(line).context("decode relay response")?;
1554    if response.request_id != request_id {
1555        bail!(
1556            "relay response ID mismatch: expected {request_id}, got {}",
1557            response.request_id
1558        );
1559    }
1560    if response.protocol_version != protocol {
1561        bail!(
1562            "relay response protocol mismatch: expected {protocol}, got {}",
1563            response.protocol_version
1564        );
1565    }
1566    match response.body {
1567        RelayResponseBody::Ok { payload } => Ok(payload),
1568        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1569    }
1570}
1571
1572fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1573    let response: RelayResponseEnvelope =
1574        serde_json::from_str(line).context("decode relay hello response")?;
1575    if response.request_id != request_id {
1576        bail!(
1577            "relay response ID mismatch: expected {request_id}, got {}",
1578            response.request_id
1579        );
1580    }
1581    match response.body {
1582        RelayResponseBody::Ok {
1583            payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1584        } => {
1585            if response.protocol_version != negotiated {
1586                bail!(
1587                    "relay hello envelope uses protocol {}, negotiated {negotiated}",
1588                    response.protocol_version
1589                );
1590            }
1591            Ok(payload)
1592        }
1593        RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1594        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1595    }
1596}
1597
1598pub struct CredentialSyncCoordinator {
1599    handle: CredentialSyncHandle,
1600    results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1601}
1602
1603impl CredentialSyncCoordinator {
1604    pub fn spawn() -> Self {
1605        let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1606        let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1607        let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1608        let (results_tx, results_rx) = mpsc::unbounded_channel();
1609        tokio::spawn(async move {
1610            let mut tick = tokio::time::interval_at(
1611                tokio::time::Instant::now() + SYNC_INTERVAL,
1612                SYNC_INTERVAL,
1613            );
1614            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1615            // A pull rewrites the canonical file, so one profile is never
1616            // reconciled twice at once.
1617            let mut busy = BTreeSet::<String>::new();
1618            let mut queue = VecDeque::<SyncTrigger>::new();
1619            loop {
1620                tokio::select! {
1621                    _ = tick.tick() => {
1622                        for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1623                            enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1624                        }
1625                    }
1626                    changed = targets_rx.changed() => {
1627                        if changed.is_err() { break; }
1628                        for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1629                            enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1630                        }
1631                    }
1632                    trigger = triggers_rx.recv() => {
1633                        let Some(trigger) = trigger else { break };
1634                        enqueue(&mut queue, trigger);
1635                    }
1636                    completed = completed_rx.recv() => {
1637                        let Some(result) = completed else { break };
1638                        busy.remove(&result.profile_id);
1639                        if result.trigger.is_some()
1640                            || result.failure.is_some()
1641                            || !result.outcomes.is_empty()
1642                        {
1643                            let profile_id = result.profile_id.clone();
1644                            if results_tx.send(result).is_err() {
1645                                tracing::debug!(
1646                                    %profile_id,
1647                                    operation = "credential_sync_result",
1648                                    "credential sync result receiver was already closed"
1649                                );
1650                            }
1651                        }
1652                    }
1653                }
1654
1655                let mut deferred = VecDeque::new();
1656                while let Some(trigger) = queue.pop_front() {
1657                    if busy.contains(&trigger.profile_id) {
1658                        deferred.push_back(trigger);
1659                        continue;
1660                    }
1661                    let targets: Vec<_> = targets_rx
1662                        .borrow()
1663                        .iter()
1664                        .filter(|target| target.profile_id == trigger.profile_id)
1665                        .cloned()
1666                        .collect();
1667                    if targets.is_empty() {
1668                        if trigger.cause.is_some() {
1669                            let profile_id = trigger.profile_id.clone();
1670                            if results_tx
1671                                .send(CredentialSyncResult {
1672                                    profile_id: trigger.profile_id,
1673                                    trigger: trigger.cause,
1674                                    failure: None,
1675                                    outcomes: Vec::new(),
1676                                })
1677                                .is_err()
1678                            {
1679                                tracing::debug!(
1680                                    %profile_id,
1681                                    operation = "credential_sync_result",
1682                                    "credential sync result receiver was already closed"
1683                                );
1684                            }
1685                        }
1686                        continue;
1687                    }
1688                    busy.insert(trigger.profile_id.clone());
1689                    let completed_tx = completed_tx.clone();
1690                    let handle = tokio::runtime::Handle::current();
1691                    // The blocking join is awaited so a panicked reconcile is
1692                    // reported and its profile always leaves the busy set.
1693                    tokio::spawn(async move {
1694                        let joined = tokio::task::spawn_blocking(move || {
1695                            handle.block_on(reconcile_profile(&targets))
1696                        })
1697                        .await;
1698                        let (failure, outcomes) = match joined {
1699                            Ok(outcomes) => (None, outcomes),
1700                            Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1701                        };
1702                        let profile_id = trigger.profile_id.clone();
1703                        if completed_tx
1704                            .send(CredentialSyncResult {
1705                                profile_id: trigger.profile_id,
1706                                trigger: trigger.cause,
1707                                failure,
1708                                outcomes,
1709                            })
1710                            .is_err()
1711                        {
1712                            tracing::debug!(
1713                                %profile_id,
1714                                operation = "credential_sync_completion",
1715                                "credential sync coordinator stopped before receiving completion"
1716                            );
1717                        }
1718                    });
1719                }
1720                queue = deferred;
1721            }
1722        });
1723        Self {
1724            handle: CredentialSyncHandle {
1725                targets: Arc::new(targets_tx),
1726                triggers: triggers_tx,
1727            },
1728            results: results_rx,
1729        }
1730    }
1731
1732    pub fn handle(&self) -> CredentialSyncHandle {
1733        self.handle.clone()
1734    }
1735
1736    pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1737        self.results.try_recv().ok()
1738    }
1739
1740    /// Waits for the next finished sync.
1741    ///
1742    /// Event-driven loops select on this instead of polling; `None` means the
1743    /// coordinator task has stopped. Cancel-safe, so a lost `select!` race
1744    /// keeps the result queued.
1745    pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1746        self.results.recv().await
1747    }
1748}
1749
1750/// Reconcile one profile with every live session that runs it.
1751///
1752/// A pull makes every other session's copy stale by definition, so the pass
1753/// runs again once with the new canonical bytes. Two passes are enough: the
1754/// second cannot pull anything the first did not already see unless a harness
1755/// refreshed mid-cycle, and that lands in the next cycle.
1756async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1757    let github_token = targets
1758        .iter()
1759        .any(|target| target.sync_github_token)
1760        .then(crate::hel_controller::controller_github_token)
1761        .flatten();
1762    let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
1763    for pass in 0..2 {
1764        let mut pulled = false;
1765        for target in targets {
1766            match reconcile_session(target, github_token.as_deref()).await {
1767                Ok(actions) if actions.is_empty() => {}
1768                Ok(actions) => {
1769                    pulled |= actions.contains(&CredentialSyncAction::Pulled);
1770                    outcomes.insert(
1771                        target.session_id.clone(),
1772                        CredentialSyncOutcome {
1773                            session_id: target.session_id.clone(),
1774                            outcome: Ok(actions),
1775                        },
1776                    );
1777                }
1778                Err(error) => {
1779                    tracing::warn!(
1780                        session_id = %target.session_id,
1781                        profile_id = %target.profile_id,
1782                        pass = pass + 1,
1783                        error = %error,
1784                        "credential synchronization failed for relay session"
1785                    );
1786                    outcomes.insert(
1787                        target.session_id.clone(),
1788                        CredentialSyncOutcome {
1789                            session_id: target.session_id.clone(),
1790                            outcome: Err(format!("{error:#}")),
1791                        },
1792                    );
1793                }
1794            }
1795        }
1796        if !pulled || pass == 1 {
1797            break;
1798        }
1799    }
1800    outcomes.into_values().collect()
1801}
1802
1803/// Returns every action taken; an empty list means the copies already agree.
1804async fn reconcile_session(
1805    target: &CredentialSyncTarget,
1806    github_token: Option<&str>,
1807) -> Result<Vec<CredentialSyncAction>> {
1808    let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
1809    let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
1810    let canonical_skills = hel::hel_skills::collect_skills(target.harness, &target.profile_home)
1811        .with_context(|| {
1812            format!(
1813                "collect canonical skills for profile {} from {}",
1814                target.profile_id,
1815                target.profile_home.display()
1816            )
1817        })?;
1818    let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
1819    let result = reconcile_connected(
1820        &mut client,
1821        target,
1822        &canonical_path,
1823        &canonical,
1824        &canonical_bytes,
1825        &canonical_skills,
1826        github_token,
1827    )
1828    .await;
1829    // Detach even when the exchange failed; the worker and harness keep
1830    // running either way. A failed detach only leaks a short-lived proxy, so it
1831    // is reported rather than turned into a sync failure.
1832    if let Err(error) = client.detach().await {
1833        tracing::warn!(
1834            session_id = %target.session_id,
1835            "could not close the credential sync connection: {error:#}"
1836        );
1837    }
1838    result
1839}
1840
1841async fn reconcile_connected(
1842    client: &mut RelayClient,
1843    target: &CredentialSyncTarget,
1844    canonical_path: &Path,
1845    canonical: &CredentialSnapshot,
1846    canonical_bytes: &[u8],
1847    canonical_skills: &hel::hel_skills::SkillsArchive,
1848    github_token: Option<&str>,
1849) -> Result<Vec<CredentialSyncAction>> {
1850    let mut actions = Vec::new();
1851    let session = client.credential_state().await?;
1852    match reconcile(canonical, &session) {
1853        SyncAction::None => {
1854            if canonical.present
1855                && session.present
1856                && canonical.fingerprint != session.fingerprint
1857                && canonical.freshness_epoch_ms.is_none()
1858                && session.freshness_epoch_ms.is_none()
1859            {
1860                tracing::warn!(
1861                    session_id = %target.session_id,
1862                    profile_id = %target.profile_id,
1863                    "credential copies differ but neither reports a refresh time; leaving both alone"
1864                );
1865            }
1866        }
1867        SyncAction::Push => {
1868            client.install_credentials(canonical_bytes).await?;
1869            actions.push(CredentialSyncAction::Pushed);
1870        }
1871        SyncAction::Pull => {
1872            let bytes = client.read_credentials().await?;
1873            validate_credential_payload(target.harness, &bytes).with_context(|| {
1874                format!(
1875                    "session {} returned an unusable credential file",
1876                    target.session_id
1877                )
1878            })?;
1879            write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
1880                format!(
1881                    "install fresher credentials from session {} for profile {}",
1882                    target.session_id, target.profile_id
1883                )
1884            })?;
1885            actions.push(CredentialSyncAction::Pulled);
1886        }
1887    }
1888    if reconcile_skills(client, target, canonical_skills).await? {
1889        actions.push(CredentialSyncAction::SkillsPushed);
1890    }
1891    if target.sync_github_token
1892        && let Some(action) = reconcile_github_token(client, target, github_token).await?
1893    {
1894        actions.push(action);
1895    }
1896    Ok(actions)
1897}
1898
1899async fn reconcile_github_token(
1900    client: &mut RelayClient,
1901    target: &CredentialSyncTarget,
1902    canonical: Option<&str>,
1903) -> Result<Option<CredentialSyncAction>> {
1904    let session = match client.github_token_state().await {
1905        Ok(state) => state,
1906        Err(error) if sync_method_unsupported(&error) => {
1907            tracing::debug!(
1908                session_id = %target.session_id,
1909                profile_id = %target.profile_id,
1910                "worker predates GitHub token sync; skipping until the target is re-provisioned"
1911            );
1912            return Ok(None);
1913        }
1914        Err(error) => return Err(error),
1915    };
1916    match canonical {
1917        Some(token) => {
1918            let canonical = hel::hel_credentials::GithubTokenSnapshot::of(token);
1919            if session == canonical {
1920                return Ok(None);
1921            }
1922            let installed = client.install_github_token(token).await?;
1923            if installed != canonical {
1924                bail!(
1925                    "session {} GitHub token fingerprint does not match the controller after install",
1926                    target.session_id
1927                );
1928            }
1929            Ok(Some(CredentialSyncAction::GithubTokenPushed))
1930        }
1931        None if session.present => {
1932            let removed = client.remove_github_token().await?;
1933            if removed.present {
1934                bail!(
1935                    "session {} retained its GitHub token after removal",
1936                    target.session_id
1937                );
1938            }
1939            Ok(Some(CredentialSyncAction::GithubTokenRemoved))
1940        }
1941        None => Ok(None),
1942    }
1943}
1944
1945/// Converge the session's synced skills trees onto the canonical archive.
1946/// Returns true when a push happened. Workers old enough to predate skills
1947/// sync answer the unknown method with `InvalidRequest`; those sessions are
1948/// skipped quietly until their target is re-provisioned.
1949async fn reconcile_skills(
1950    client: &mut RelayClient,
1951    target: &CredentialSyncTarget,
1952    canonical: &hel::hel_skills::SkillsArchive,
1953) -> Result<bool> {
1954    let canonical_state = canonical.state();
1955    let session = match client.skills_state().await {
1956        Ok(state) => state,
1957        Err(error) if sync_method_unsupported(&error) => {
1958            tracing::debug!(
1959                session_id = %target.session_id,
1960                profile_id = %target.profile_id,
1961                "worker predates skills sync; skipping until the target is re-provisioned"
1962            );
1963            return Ok(false);
1964        }
1965        Err(error) => return Err(error),
1966    };
1967    if session == canonical_state {
1968        return Ok(false);
1969    }
1970    let installed = client.install_skills(&canonical.encode()).await?;
1971    if installed != canonical_state {
1972        bail!(
1973            "session {} skills fingerprint {} does not match the canonical {} after install",
1974            target.session_id,
1975            installed.fingerprint,
1976            canonical_state.fingerprint
1977        );
1978    }
1979    Ok(true)
1980}
1981
1982fn sync_method_unsupported(error: &anyhow::Error) -> bool {
1983    error
1984        .downcast_ref::<RelayRejected>()
1985        .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990    use super::*;
1991    use hel::hel_worker::{DurableRelay, RelayObservation};
1992    const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
1993
1994    #[test]
1995    fn relay_decoder_preserves_explicit_desynchronization() {
1996        let response = RelayResponseEnvelope {
1997            request_id: "relay-1".into(),
1998            protocol_version: RELAY_PROTOCOL_VERSION,
1999            body: RelayResponseBody::Error {
2000                error: RelayProtocolError {
2001                    code: RelayErrorCode::Desynchronized,
2002                    message: "journal gap".into(),
2003                    retryable: false,
2004                    detail: None,
2005                },
2006            },
2007        };
2008        let encoded = serde_json::to_string(&response).unwrap();
2009        let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2010        assert!(
2011            error
2012                .downcast_ref::<RelayRejected>()
2013                .is_some_and(RelayRejected::is_desynchronized)
2014        );
2015    }
2016
2017    #[test]
2018    fn relay_decoder_rejects_crossed_request_ids() {
2019        let response = RelayResponseEnvelope {
2020            request_id: "other".into(),
2021            protocol_version: RELAY_PROTOCOL_VERSION,
2022            body: RelayResponseBody::Ok {
2023                payload: RelayResponsePayload::Acknowledged {
2024                    through_ordinal: 4,
2025                    through_digest: "a".repeat(64),
2026                },
2027            },
2028        };
2029        let encoded = serde_json::to_string(&response).unwrap();
2030        assert!(
2031            decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
2032                .unwrap_err()
2033                .to_string()
2034                .contains("ID mismatch")
2035        );
2036    }
2037
2038    #[test]
2039    fn command_spec_preserves_argv_boundaries() {
2040        let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
2041        assert_eq!(spec.program, "ssh");
2042        assert_eq!(spec.args.len(), 2);
2043        assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
2044    }
2045
2046    #[test]
2047    fn relay_protocol_version_range_contains_current_version() {
2048        assert_eq!(
2049            RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
2050            Some(RELAY_PROTOCOL_VERSION)
2051        );
2052        assert_eq!(
2053            RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
2054            Some(1)
2055        );
2056    }
2057
2058    #[cfg(unix)]
2059    #[tokio::test]
2060    async fn controller_accepts_negotiated_protocol_v1() {
2061        let script = format!(
2062            r#"python3 -c '
2063import json, sys
2064session = {session:?}
2065req = json.loads(sys.stdin.readline())
2066assert req["request"]["method"] == "hello"
2067supported = req["request"]["params"]["supported"]
2068assert supported["min"] <= 1 <= supported["max"]
2069print(json.dumps({{
2070    "request_id": req["request_id"],
2071    "protocol_version": 1,
2072    "result": "ok",
2073    "payload": {{
2074        "type": "hello",
2075        "data": {{
2076            "negotiated": 1,
2077            "relay_version": "v1-fixture",
2078            "session_id": session,
2079        }},
2080    }},
2081}}), flush=True)
2082sys.stdin.read()
2083'"#,
2084            session = SESSION_ID
2085        );
2086        let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2087        let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2088            .await
2089            .expect("protocol v1 hello must be accepted");
2090        assert_eq!(client.protocol_version(), 1);
2091        assert_eq!(client.relay_version(), "v1-fixture");
2092    }
2093
2094    /// The build a worker reports is what decides whether it is replaced, so a
2095    /// controller has to read it from hello - and read a worker that reports
2096    /// none as exactly that, rather than failing the handshake.
2097    #[cfg(unix)]
2098    #[tokio::test]
2099    async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2100        let hello = |build: Option<&str>| {
2101            let data = match build {
2102                Some(build) => format!(
2103                    r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2104                ),
2105                None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2106                    .to_owned(),
2107            };
2108            format!(
2109                r#"
2110IFS= read -r hello
2111id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2112printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2113' "$id" "$1"
2114sh -c 'while :; do sleep 30; done'
2115"#
2116            )
2117        };
2118        for reported in [None, Some("a".repeat(64).as_str())] {
2119            let spec = CommandSpec::new(
2120                "sh",
2121                [
2122                    "-c".to_owned(),
2123                    hello(reported),
2124                    "hel-relay-build-fixture".to_owned(),
2125                    SESSION_ID.to_owned(),
2126                ],
2127            )
2128            .purpose("relay worker build fixture");
2129            let client =
2130                RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2131                    .await
2132                    .expect("hello must be accepted with and without a worker build");
2133            assert_eq!(client.worker_build(), reported);
2134        }
2135    }
2136
2137    #[cfg(unix)]
2138    #[tokio::test]
2139    async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2140        let directory = tempfile::tempdir().unwrap();
2141        let eof = directory.path().join("proxy-saw-eof");
2142        let script = r#"
2143IFS= read -r hello
2144id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2145printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2146if IFS= read -r _; then exit 9; fi
2147: > "$2"
2148"#;
2149        let spec = CommandSpec::new(
2150            "sh",
2151            [
2152                "-c".to_owned(),
2153                script.to_owned(),
2154                "hel-relay-eof-fixture".to_owned(),
2155                SESSION_ID.to_owned(),
2156                eof.to_string_lossy().into_owned(),
2157            ],
2158        )
2159        .purpose("relay proxy EOF fixture");
2160        let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2161            .await
2162            .unwrap();
2163
2164        drop(client);
2165        tokio::time::timeout(Duration::from_secs(2), async {
2166            while !eof.exists() {
2167                tokio::time::sleep(Duration::from_millis(10)).await;
2168            }
2169        })
2170        .await
2171        .expect("proxy launcher was killed before it observed stdin EOF");
2172    }
2173
2174    #[cfg(unix)]
2175    #[tokio::test]
2176    async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2177        let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2178        let script = format!(
2179            r#"python3 -c '
2180import json, sys
2181session = {session:?}
2182req = json.loads(sys.stdin.readline())
2183print(json.dumps({{
2184    "request_id": req["request_id"],
2185    "protocol_version": {future_protocol},
2186    "result": "ok",
2187    "payload": {{
2188        "type": "hello",
2189        "data": {{
2190            "negotiated": {future_protocol},
2191            "relay_version": "future",
2192            "session_id": session,
2193        }},
2194    }},
2195}}), flush=True)
2196sys.stdin.read()
2197'"#,
2198            session = SESSION_ID,
2199            future_protocol = future_protocol,
2200        );
2201        let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2202        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2203            .await
2204            .err()
2205            .expect("a future protocol hello must be rejected");
2206        assert!(
2207            error.to_string().contains(&format!(
2208                "negotiated unsupported protocol {future_protocol}"
2209            )),
2210            "{error:#}"
2211        );
2212        // The transport carried the answer perfectly well; restarting the
2213        // worker cannot make it speak a protocol it does not implement.
2214        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2215    }
2216
2217    /// A proxy that exits without answering is the ordinary shape of a dead
2218    /// worker. Recovery hangs on this being typed rather than read.
2219    #[cfg(unix)]
2220    #[tokio::test]
2221    async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2222        let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2223
2224        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2225            .await
2226            .err()
2227            .expect("a proxy that exits cannot complete hello");
2228
2229        assert!(RelayTransportDead::marks(&error), "{error:#}");
2230        assert!(RelayTransportDead::marks_failed_handshake(&error));
2231    }
2232
2233    #[cfg(unix)]
2234    #[tokio::test]
2235    async fn silent_proxy_handshake_has_a_bounded_deadline() {
2236        let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2237        let started = std::time::Instant::now();
2238
2239        let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2240            .await
2241            .err()
2242            .expect("silent relay must time out");
2243
2244        assert!(error.to_string().contains("relay hello timed out"));
2245        // The launcher is still alive. A loaded target can look exactly like
2246        // this while starting its proxy, so worker recovery must not restart
2247        // the native session merely because the deadline elapsed.
2248        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2249        assert!(!RelayTransportDead::marks_failed_handshake(&error));
2250        assert!(started.elapsed() < Duration::from_secs(2));
2251    }
2252
2253    /// A relay that answers `hello` at once and then stalls, replying to the
2254    /// next request long after any controller deadline. `$1` is the session id.
2255    #[cfg(unix)]
2256    const STALLING_RELAY: &str = r#"
2257IFS= read -r hello
2258id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2259printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2260IFS= read -r stalled
2261id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2262sleep 5
2263printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2264cat > /dev/null
2265"#;
2266
2267    #[cfg(unix)]
2268    #[tokio::test]
2269    async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2270        let spec = CommandSpec::new(
2271            "sh",
2272            ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2273        )
2274        .purpose("stalling relay fixture");
2275        let mut client =
2276            RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2277                .await
2278                .expect("the fixture answers hello immediately");
2279
2280        let timed_out = client
2281            .status()
2282            .await
2283            .expect_err("the stalled status call must time out");
2284        assert!(
2285            format!("{timed_out:#}").contains("relay status timed out"),
2286            "{timed_out:#}"
2287        );
2288        // A busy worker that misses one deadline is not a dead transport: it
2289        // answered the handshake, and killing it would be worse than waiting.
2290        assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2291
2292        // The abandoned reply is still in flight. A later call must not read it
2293        // as its own response, so it fails at once with the real cause. The
2294        // normal request deadline is long enough that without this the
2295        // controller would block on someone else's reply.
2296        let started = std::time::Instant::now();
2297        let subsequent = client
2298            .status()
2299            .await
2300            .expect_err("a call on an abandoned connection must fail");
2301        let elapsed = started.elapsed();
2302        assert!(
2303            format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2304            "{subsequent:#}"
2305        );
2306        assert!(
2307            elapsed < Duration::from_millis(250),
2308            "an abandoned connection must fail fast, took {elapsed:?}"
2309        );
2310
2311        let repeated = client
2312            .status()
2313            .await
2314            .expect_err("the connection stays abandoned");
2315        assert!(
2316            format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2317            "{repeated:#}"
2318        );
2319    }
2320
2321    #[test]
2322    fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2323        // Workers that predate skills sync answer the unknown method with an
2324        // `InvalidRequest` rejection, and so does a current worker's structured
2325        // unsupported-method response. Both must skip the session quietly.
2326        let response = hel::hel_worker::unsupported_relay_method_response(
2327            "relay-1".into(),
2328            RELAY_PROTOCOL_VERSION,
2329            "skills_state".into(),
2330        );
2331        let encoded = serde_json::to_string(&response).unwrap();
2332        let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2333        assert!(sync_method_unsupported(&error), "{error:#}");
2334    }
2335
2336    #[tokio::test]
2337    async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2338        let profile = tempfile::tempdir().unwrap();
2339        let mut coordinator = CredentialSyncCoordinator::spawn();
2340        coordinator.handle().set_targets(vec![CredentialSyncTarget {
2341            session_id: SESSION_ID.into(),
2342            profile_id: "work".into(),
2343            harness: hel::hel_config::HarnessKind::Codex,
2344            profile_home: profile.path().to_path_buf(),
2345            sync_github_token: false,
2346            spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2347        }]);
2348
2349        let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2350            .await
2351            .expect("target publication must not wait for the 60-second periodic tick")
2352            .expect("credential coordinator stopped");
2353        assert_eq!(result.profile_id, "work");
2354        assert_eq!(result.outcomes.len(), 1);
2355        assert!(result.outcomes[0].outcome.is_err());
2356    }
2357
2358    #[tokio::test]
2359    async fn response_frame_limit_is_enforced_before_newline() {
2360        let (mut writer, reader) = tokio::io::duplex(32);
2361        let write = tokio::spawn(async move {
2362            writer.write_all(b"123456789\n").await.unwrap();
2363        });
2364        let mut reader = BufReader::new(reader);
2365
2366        let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2367            .await
2368            .unwrap_err();
2369
2370        write.await.unwrap();
2371        assert!(error.to_string().contains("frame is too large"));
2372        // An oversized frame is a protocol violation, not a dead transport:
2373        // the same worker would send the same frame after a restart.
2374        assert!(!RelayTransportDead::marks(&error), "{error:#}");
2375    }
2376
2377    #[tokio::test]
2378    async fn a_half_written_response_frame_reports_a_dead_transport() {
2379        let (mut writer, reader) = tokio::io::duplex(32);
2380        writer.write_all(b"{\"partial\":").await.unwrap();
2381        drop(writer);
2382        let mut reader = BufReader::new(reader);
2383
2384        let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2385            .await
2386            .unwrap_err();
2387
2388        assert!(RelayTransportDead::marks(&error), "{error:#}");
2389        assert!(!RelayTransportDead::marks_failed_handshake(&error));
2390    }
2391
2392    #[test]
2393    fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2394        let temp = tempfile::tempdir().unwrap();
2395        let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2396        for message in ["one", "two", "arrived concurrently"] {
2397            relay
2398                .record_observation(RelayObservation::Warning {
2399                    message: message.into(),
2400                })
2401                .unwrap();
2402        }
2403        let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2404        let previous = RelayCursor {
2405            ordinal: all[0].ordinal,
2406            digest: all[0].digest.clone(),
2407        };
2408        let frontier = RelayCursor {
2409            ordinal: all[1].ordinal,
2410            digest: all[1].digest.clone(),
2411        };
2412        let page = RelayAttachment {
2413            state: relay.operational_state(),
2414            events: all[1..].to_vec(),
2415            through_ordinal: all[2].ordinal,
2416            through_digest: all[2].digest.clone(),
2417        };
2418        let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2419        assert_eq!(clipped.through_ordinal, frontier.ordinal);
2420        assert_eq!(clipped.through_digest, frontier.digest);
2421        assert_eq!(clipped.events.len(), 1);
2422        assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2423    }
2424}