Skip to main content

mj_controller/hel_controller/
recovery_scan.rs

1//! Orphan-worker discovery, adoption, and destruction.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7use crate::hel_session_manager::StandaloneSession;
8use hel::hel_config::{AwsAddressSource, SshConnection, TargetTemplate};
9use hel::hel_state::{SessionRecord, SessionState, TargetLocator, normalize_session_title};
10use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
11use hel::hel_worker_launch::WorkerOwnership;
12
13use super::backend::{ContainerOverrides, backend_locator, backend_target};
14use super::readiness::wait_for_native_session;
15use super::{Controller, backend_ssh, now, ssh_args_with_identity};
16
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct RecoveryCandidate {
19    pub session_id: String,
20    pub target_template_id: String,
21    pub locator: TargetLocator,
22    pub ownership: Option<WorkerOwnership>,
23}
24
25#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
26pub struct RecoveryScan {
27    pub candidates: Vec<RecoveryCandidate>,
28    pub warnings: Vec<String>,
29}
30
31impl Controller {
32    /// Find managed resources which are not represented by the controller's
33    /// current state. Labels/tags establish Hel ownership; the worker marker
34    /// supplies profile and bundle metadata when it is available.
35    pub fn scan_orphan_workers(&self, executor: &impl CommandExecutor) -> RecoveryScan {
36        let mut scan = RecoveryScan::default();
37        for (target_id, template) in &self.config.targets {
38            match scan_target_workers(target_id, template, executor) {
39                Ok(candidates) => {
40                    scan.candidates
41                        .extend(candidates.into_iter().filter(|candidate| {
42                            !self.state.sessions.contains_key(&candidate.session_id)
43                        }))
44                }
45                Err(error) => scan.warnings.push(format!("target {target_id}: {error:#}")),
46            }
47        }
48        scan.candidates.sort_by(|left, right| {
49            (&left.session_id, &left.target_template_id)
50                .cmp(&(&right.session_id, &right.target_template_id))
51        });
52        scan.candidates.dedup_by(|left, right| {
53            left.session_id == right.session_id
54                && left.target_template_id == right.target_template_id
55        });
56        scan
57    }
58
59    pub async fn adopt_orphan_worker(
60        &mut self,
61        session_id: &str,
62        target_id: &str,
63        profile_override: Option<&str>,
64        bundle_override: Option<&str>,
65        executor: &impl CommandExecutor,
66    ) -> Result<()> {
67        let (record, newly_adopted) = match self.state.sessions.get(session_id).cloned() {
68            // Adoption records its session before the handshake, so a failed
69            // handshake leaves a tracked session that never connected. That
70            // record is the one to finish, not a reason to refuse the retry.
71            Some(existing) if adoption_unfinished(&existing, target_id) => {
72                for (flag, requested, adopted) in [
73                    ("profile", profile_override, existing.last_profile.as_str()),
74                    ("bundle", bundle_override, existing.bundle_id.as_str()),
75                ] {
76                    if let Some(requested) = requested
77                        && requested != adopted
78                    {
79                        bail!(
80                            "session {session_id} was already adopted with {flag} {adopted:?}; retry without --{flag}"
81                        );
82                    }
83                }
84                (existing, false)
85            }
86            Some(_) => bail!("session {session_id} is already tracked"),
87            None => {
88                let candidate = self
89                    .scan_orphan_workers(executor)
90                    .candidates
91                    .into_iter()
92                    .find(|candidate| {
93                        candidate.session_id == session_id
94                            && candidate.target_template_id == target_id
95                    })
96                    .with_context(|| {
97                        format!("no managed orphan {session_id} was found on target {target_id:?}")
98                    })?;
99                let profile_id = profile_override
100                    .map(str::to_owned)
101                    .or_else(|| {
102                        candidate
103                            .ownership
104                            .as_ref()
105                            .map(|marker| marker.profile_id.clone())
106                    })
107                    .context("orphan has no ownership marker; pass --profile")?;
108                let bundle_id = bundle_override
109                    .map(str::to_owned)
110                    .or_else(|| {
111                        candidate
112                            .ownership
113                            .as_ref()
114                            .map(|marker| marker.bundle_id.clone())
115                    })
116                    .context("orphan has no ownership marker; pass --bundle")?;
117                let profile = self
118                    .config
119                    .profiles
120                    .get(&profile_id)
121                    .with_context(|| format!("unknown profile {profile_id:?}"))?;
122                self.config
123                    .bundles
124                    .get(&bundle_id)
125                    .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
126                let record = adopted_session_record(
127                    session_id,
128                    target_id,
129                    profile_id,
130                    profile.kind,
131                    bundle_id,
132                    candidate
133                        .ownership
134                        .as_ref()
135                        .map(|ownership| ownership.workspace_id.clone())
136                        .unwrap_or_else(|| hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned()),
137                    candidate.locator,
138                );
139                (record, true)
140            }
141        };
142        let locator = record
143            .target
144            .as_ref()
145            .context("adopted session has no target locator")?;
146        let backend = backend_locator(locator, &record, &self.config)?;
147        let spec = hel_targets::reconnect_plan(&backend, session_id)?
148            .commands
149            .into_iter()
150            .next()
151            .context("reconnect plan is empty")?;
152        if newly_adopted {
153            // Adoption authors the whole record for a session Hel has never
154            // tracked, so it writes the whole row, and it writes it before the
155            // handshake: a crash in between must not orphan the worker again.
156            // The record reaches memory only once it is durable.
157            hel::hel_database::save_session(&record)?;
158            self.state.sessions.insert(session_id.to_owned(), record);
159        }
160        match self.complete_adoption(session_id, &spec, executor).await {
161            Ok(()) => Ok(()),
162            // Provisioning leaves its failure on the session it failed for.
163            // Adoption owes the same: the record it already committed is all
164            // the user has to see why the worker never connected.
165            Err(error) => Err(self.record_adoption_failure(session_id, error)),
166        }
167    }
168
169    /// Connect the adopted worker's relay and promote the session to running.
170    async fn complete_adoption(
171        &mut self,
172        session_id: &str,
173        spec: &CommandSpec,
174        executor: &impl CommandExecutor,
175    ) -> Result<()> {
176        let mut relay = StandaloneSession::connect_command(spec, session_id)
177            .await
178            .context("orphan relay did not complete the v1 handshake")?;
179        let native_session_id = wait_for_native_session(&mut relay, executor).await?;
180        self.mark_worker_connected(session_id, Some(native_session_id))?;
181        if let Some(title) = relay
182            .snapshot()
183            .materialized
184            .session_title
185            .as_deref()
186            .and_then(normalize_session_title)
187        {
188            hel::hel_database::set_session_acp_title(session_id, Some(&title))?;
189            self.state
190                .sessions
191                .get_mut(session_id)
192                .expect("adopted session disappeared while saving its ACP title")
193                .acp_session_title = Some(title);
194        }
195        Ok(())
196    }
197
198    /// Leave a failed adoption on the session itself. The state stays
199    /// `Disconnected`, which is the truth — the target exists and no worker is
200    /// connected — and keeps the record adoptable so the handshake can be
201    /// retried once the worker is reachable again.
202    fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
203        let Some(record) = self.state.sessions.get_mut(session_id) else {
204            return error;
205        };
206        record.updated_at = now();
207        record.last_error = Some(format!("orphan adoption failed: {error:#}"));
208        match self.persist_session_state(session_id) {
209            Ok(()) => error,
210            Err(persist_error) => error.context(format!(
211                "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
212            )),
213        }
214    }
215
216    pub fn destroy_orphan_worker(
217        &self,
218        session_id: &str,
219        target_id: &str,
220        confirmation: &str,
221        executor: &impl CommandExecutor,
222    ) -> Result<()> {
223        if confirmation != session_id {
224            bail!("refusing destructive recovery: --confirm must exactly match the session ID");
225        }
226        let candidate = self
227            .scan_orphan_workers(executor)
228            .candidates
229            .into_iter()
230            .find(|candidate| {
231                candidate.session_id == session_id && candidate.target_template_id == target_id
232            })
233            .with_context(|| {
234                format!("no managed orphan {session_id} was found on target {target_id:?}")
235            })?;
236        let template = self.config.targets.get(target_id).unwrap();
237        let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
238        hel_targets::close_plan(&backend, session_id)?
239            .execute(executor)
240            .map(|_| ())
241    }
242}
243
244/// The session record adoption commits before it tries the relay handshake.
245fn adopted_session_record(
246    session_id: &str,
247    target_id: &str,
248    profile_id: String,
249    harness_kind: hel::hel_config::HarnessKind,
250    bundle_id: String,
251    workspace_id: String,
252    locator: TargetLocator,
253) -> SessionRecord {
254    let now = now();
255    SessionRecord {
256        workspace_id,
257        archived: false,
258        container_cpus: None,
259        container_memory: None,
260        id: session_id.to_owned(),
261        title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
262        harness_kind,
263        last_profile: profile_id,
264        bundle_id,
265        project_directory: None,
266        managed_worktree: None,
267        target_template_id: target_id.to_owned(),
268        resource_allocation: None,
269        additional_mounts: Vec::new(),
270        state: SessionState::Disconnected,
271        target: Some(locator),
272        native_session_id: None,
273        acp_session_title: None,
274        session_title_override: None,
275        created_at: now.clone(),
276        updated_at: now,
277        viewed_through_event_ordinal: 0,
278        draft_input: String::new(),
279        last_error: None,
280        last_checkpoint_error: None,
281        checkpoint: None,
282    }
283}
284
285/// Whether a tracked session is one an adoption committed and never finished:
286/// it names this target, carries the locator the scan found, and no harness
287/// session has ever been observed on it. Such a record is the retry, so
288/// adoption completes it instead of refusing it as already tracked.
289fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
290    record.state == SessionState::Disconnected
291        && record.native_session_id.is_none()
292        && record.target_template_id == target_id
293        && record.target.is_some()
294}
295
296fn scan_target_workers(
297    target_id: &str,
298    template: &TargetTemplate,
299    executor: &impl CommandExecutor,
300) -> Result<Vec<RecoveryCandidate>> {
301    let mut candidates = match template {
302        // Local bare sessions persist their locator in the controller database.
303        // Do not infer an adoptable project from Hel's transient worker directory.
304        TargetTemplate::LocalBare => Vec::new(),
305        TargetTemplate::LocalPodman { .. } => scan_container_engine(
306            target_id,
307            template,
308            "podman",
309            vec![
310                "ps".into(),
311                "--all".into(),
312                "--filter".into(),
313                format!("label={}=true", hel_targets::MANAGED_LABEL),
314                "--format".into(),
315                "json".into(),
316            ],
317            executor,
318        )?,
319        TargetTemplate::LocalDocker { .. } => scan_container_engine(
320            target_id,
321            template,
322            "docker",
323            vec![
324                "ps".into(),
325                "--all".into(),
326                "--filter".into(),
327                format!("label={}=true", hel_targets::MANAGED_LABEL),
328                "--format".into(),
329                "json".into(),
330            ],
331            executor,
332        )?,
333        TargetTemplate::AppleContainer { .. } => scan_container_engine(
334            target_id,
335            template,
336            "container",
337            vec![
338                "list".into(),
339                "--all".into(),
340                "--format".into(),
341                "json".into(),
342            ],
343            executor,
344        )?,
345        TargetTemplate::SshPodman { ssh, .. } => {
346            let remote = hel_targets::join_remote_command(&[
347                "podman".into(),
348                "ps".into(),
349                "--all".into(),
350                "--filter".into(),
351                format!("label={}=true", hel_targets::MANAGED_LABEL),
352                "--format".into(),
353                "json".into(),
354            ]);
355            let output = execute_scan(
356                executor,
357                ssh_spec(ssh, [remote]),
358                "scan remote Podman workers",
359            )?;
360            candidates_from_container_json(target_id, template, &output.stdout)?
361        }
362        TargetTemplate::AwsEc2 {
363            aws_profile,
364            region,
365            address_source,
366            ..
367        } => {
368            let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
369            let output = execute_scan(
370                executor,
371                CommandSpec::new(
372                    "aws",
373                    [
374                        "--profile".into(),
375                        profile,
376                        "--region".into(),
377                        region.clone(),
378                        "ec2".into(),
379                        "describe-instances".into(),
380                        "--filters".into(),
381                        format!("Name=tag:{},Values=true", hel_targets::MANAGED_TAG),
382                        "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
383                        "--output".into(),
384                        "json".into(),
385                    ],
386                )
387                .purpose("scan managed EC2 workers"),
388                "scan managed EC2 workers",
389            )?;
390            candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
391        }
392        TargetTemplate::SshBare { ssh, .. } => {
393            let output = execute_scan(
394                executor,
395                ssh_spec(
396                    ssh,
397                    [hel_targets::join_remote_command(&[
398                        "find".into(),
399                        ".local/share/hel/workers".into(),
400                        "-mindepth".into(),
401                        "2".into(),
402                        "-maxdepth".into(),
403                        "2".into(),
404                        "-name".into(),
405                        "ownership.json".into(),
406                        "-print".into(),
407                    ])],
408                ),
409                "scan bare SSH worker markers",
410            )?;
411            output
412                .stdout
413                .split(|byte| *byte == b'\n')
414                .filter_map(|line| {
415                    let path = match std::str::from_utf8(line) {
416                        Ok(path) => path.trim(),
417                        Err(error) => {
418                            tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
419                            return None;
420                        }
421                    };
422                    let Some(session_id) = Path::new(path)
423                        .parent()
424                        .and_then(|parent| parent.file_name())
425                        .and_then(|name| name.to_str())
426                    else {
427                        tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
428                        return None;
429                    };
430                    if let Err(error) = hel_targets::resource_name(session_id) {
431                        tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
432                        return None;
433                    }
434                    let backend = match backend_target(template, None, ContainerOverrides::default()) {
435                        Ok(backend) => backend,
436                        Err(error) => {
437                            tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
438                            return None;
439                        }
440                    };
441                    let workspace = match hel_targets::workspace_for(&backend, session_id) {
442                        Ok(workspace) => workspace,
443                        Err(error) => {
444                            tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
445                            return None;
446                        }
447                    };
448                    Some(RecoveryCandidate {
449                        session_id: session_id.to_owned(),
450                        target_template_id: target_id.to_owned(),
451                        locator: TargetLocator::SshBare {
452                            host: ssh.host.clone(),
453                            workspace: PathBuf::from(workspace),
454                            worker_id: None,
455                        },
456                        ownership: None,
457                    })
458                })
459                .collect()
460        }
461    };
462    for candidate in &mut candidates {
463        candidate.ownership = read_recovery_ownership(template, candidate, executor);
464    }
465    Ok(candidates)
466}
467
468fn scan_container_engine(
469    target_id: &str,
470    template: &TargetTemplate,
471    engine: &str,
472    args: Vec<String>,
473    executor: &impl CommandExecutor,
474) -> Result<Vec<RecoveryCandidate>> {
475    let output = execute_scan(
476        executor,
477        CommandSpec::new(engine, args).purpose("scan managed container workers"),
478        "scan managed container workers",
479    )?;
480    candidates_from_container_json(target_id, template, &output.stdout)
481}
482
483fn candidates_from_container_json(
484    target_id: &str,
485    template: &TargetTemplate,
486    stdout: &[u8],
487) -> Result<Vec<RecoveryCandidate>> {
488    let sessions = managed_sessions_from_container_json(stdout)?;
489    Ok(sessions
490        .into_iter()
491        .filter_map(|session_id| {
492            let generated = match hel_targets::resource_name(&session_id) {
493                Ok(generated) => generated,
494                Err(error) => {
495                    tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
496                    return None;
497                }
498            };
499            let locator = match template {
500                TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
501                    container_id: generated,
502                    workspace_storage: Default::default(),
503                },
504                TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
505                    container_id: generated,
506                },
507                TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
508                    container_id: generated,
509                },
510                TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
511                    host: ssh.host.clone(),
512                    container_id: generated,
513                    workspace_storage: Default::default(),
514                },
515                _ => return None,
516            };
517            Some(RecoveryCandidate {
518                session_id,
519                target_template_id: target_id.to_owned(),
520                locator,
521                ownership: None,
522            })
523        })
524        .collect())
525}
526
527pub(super) fn managed_sessions_from_container_json(stdout: &[u8]) -> Result<Vec<String>> {
528    let values = serde_json::Deserializer::from_slice(stdout)
529        .into_iter::<serde_json::Value>()
530        .collect::<std::result::Result<Vec<_>, _>>()
531        .context("parse container list JSON")?;
532    let mut sessions = Vec::new();
533    for value in &values {
534        collect_managed_sessions(value, &mut sessions);
535    }
536    sessions.sort();
537    sessions.dedup();
538    Ok(sessions)
539}
540
541pub(super) fn collect_managed_sessions(value: &serde_json::Value, sessions: &mut Vec<String>) {
542    match value {
543        serde_json::Value::Array(values) => {
544            for value in values {
545                collect_managed_sessions(value, sessions);
546            }
547        }
548        serde_json::Value::Object(object) => {
549            for label_key in ["Labels", "labels"] {
550                if let Some(labels) = object.get(label_key) {
551                    let managed = label_value(labels, hel_targets::MANAGED_LABEL)
552                        .is_some_and(|value| value == "true");
553                    if managed
554                        && let Some(session) = label_value(labels, hel_targets::SESSION_LABEL)
555                    {
556                        sessions.push(session);
557                    }
558                }
559            }
560            for value in object.values() {
561                collect_managed_sessions(value, sessions);
562            }
563        }
564        _ => {}
565    }
566}
567
568fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
569    match labels {
570        serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
571        serde_json::Value::String(text) => text
572            .split(',')
573            .find_map(|label| {
574                label
575                    .trim()
576                    .split_once('=')
577                    .filter(|(name, _)| *name == key)
578            })
579            .map(|(_, value)| value.to_owned()),
580        _ => None,
581    }
582}
583
584fn candidates_from_aws_json(
585    target_id: &str,
586    address_source: AwsAddressSource,
587    stdout: &[u8],
588) -> Result<Vec<RecoveryCandidate>> {
589    let value: serde_json::Value =
590        serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
591    let mut result = Vec::new();
592    let reservations = value
593        .get("Reservations")
594        .and_then(serde_json::Value::as_array)
595        .cloned()
596        .unwrap_or_default();
597    for instance in reservations.iter().flat_map(|reservation| {
598        reservation
599            .get("Instances")
600            .and_then(serde_json::Value::as_array)
601            .into_iter()
602            .flatten()
603    }) {
604        let tags = instance
605            .get("Tags")
606            .and_then(serde_json::Value::as_array)
607            .cloned()
608            .unwrap_or_default();
609        let tag = |key: &str| {
610            tags.iter()
611                .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
612                .and_then(|tag| tag.get("Value"))
613                .and_then(serde_json::Value::as_str)
614        };
615        if tag(hel_targets::MANAGED_TAG) != Some("true") {
616            continue;
617        }
618        let Some(session_id) = tag(hel_targets::SESSION_TAG).map(str::to_owned) else {
619            continue;
620        };
621        hel_targets::resource_name(&session_id)?;
622        let instance_id = instance
623            .get("InstanceId")
624            .and_then(serde_json::Value::as_str)
625            .context("managed EC2 instance omitted InstanceId")?
626            .to_owned();
627        let field = match address_source {
628            AwsAddressSource::PublicDns => "PublicDnsName",
629            AwsAddressSource::PublicIp => "PublicIpAddress",
630            AwsAddressSource::PrivateDns => "PrivateDnsName",
631            AwsAddressSource::PrivateIp => "PrivateIpAddress",
632        };
633        let address = instance
634            .get(field)
635            .and_then(serde_json::Value::as_str)
636            .filter(|value| !value.is_empty())
637            .map(str::to_owned);
638        result.push(RecoveryCandidate {
639            session_id,
640            target_template_id: target_id.to_owned(),
641            locator: TargetLocator::AwsEc2 {
642                instance_id,
643                address,
644            },
645            ownership: None,
646        });
647    }
648    Ok(result)
649}
650
651fn execute_scan(
652    executor: &impl CommandExecutor,
653    command: CommandSpec,
654    operation: &str,
655) -> Result<CommandOutput> {
656    let output = executor.execute(&command)?;
657    if output.status != 0 {
658        bail!(
659            "{operation} failed with status {}: {}",
660            output.status,
661            String::from_utf8_lossy(&output.stderr).trim()
662        );
663    }
664    Ok(output)
665}
666
667fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
668    let backend = backend_ssh(ssh);
669    let mut args = backend.ssh_args;
670    args.push(backend.destination);
671    args.extend(remote);
672    CommandSpec::new("ssh", args)
673}
674
675fn read_recovery_ownership(
676    template: &TargetTemplate,
677    candidate: &RecoveryCandidate,
678    executor: &impl CommandExecutor,
679) -> Option<WorkerOwnership> {
680    let backend =
681        match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
682            Ok(backend) => backend,
683            Err(error) => {
684                tracing::debug!(
685                    session_id = %candidate.session_id,
686                    %error,
687                    "could not construct a recovery ownership probe"
688                );
689                return None;
690            }
691        };
692    let root = match hel_targets::worker_root(&backend, &candidate.session_id) {
693        Ok(root) => root,
694        Err(error) => {
695            tracing::debug!(
696                session_id = %candidate.session_id,
697                %error,
698                "could not derive a recovery worker root"
699            );
700            return None;
701        }
702    };
703    let command = match hel_targets::command_on_locator(
704        &backend,
705        &candidate.session_id,
706        vec!["cat".into(), format!("{root}/ownership.json")],
707        "read worker ownership marker",
708    ) {
709        Ok(command) => command,
710        Err(error) => {
711            tracing::debug!(
712                session_id = %candidate.session_id,
713                %error,
714                "could not construct a recovery ownership command"
715            );
716            return None;
717        }
718    };
719    let output = match executor.execute(&command) {
720        Ok(output) => output,
721        Err(error) => {
722            tracing::debug!(
723                session_id = %candidate.session_id,
724                %error,
725                "could not read a recovery worker ownership marker"
726            );
727            return None;
728        }
729    };
730    if output.status != 0 {
731        tracing::debug!(
732            session_id = %candidate.session_id,
733            status = output.status,
734            "recovery worker ownership probe returned a failure"
735        );
736        return None;
737    }
738    let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
739        Ok(marker) => marker,
740        Err(error) => {
741            tracing::debug!(
742                session_id = %candidate.session_id,
743                %error,
744                "recovery worker ownership marker was not valid JSON"
745            );
746            return None;
747        }
748    };
749    if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
750        || marker.session_id != candidate.session_id
751        || marker.target_template_id != candidate.target_template_id
752    {
753        tracing::debug!(
754            session_id = %candidate.session_id,
755            marker_session_id = %marker.session_id,
756            marker_target_template_id = %marker.target_template_id,
757            "recovery worker ownership marker did not match the candidate"
758        );
759        return None;
760    }
761    Some(marker)
762}
763
764fn recovery_backend_locator(
765    template: &TargetTemplate,
766    locator: &TargetLocator,
767    session_id: &str,
768) -> Result<hel_targets::TargetLocator> {
769    Ok(match (template, locator) {
770        (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
771            hel_targets::TargetLocator::LocalBare {
772                worker_root: worker_root.to_string_lossy().into_owned(),
773            }
774        }
775        (TargetTemplate::LocalPodman { .. }, TargetLocator::LocalPodman { container_id, .. }) => {
776            hel_targets::TargetLocator::LocalPodman {
777                container_id: container_id.clone(),
778                workspace_storage: Default::default(),
779            }
780        }
781        (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id }) => {
782            hel_targets::TargetLocator::LocalDocker {
783                container_id: container_id.clone(),
784            }
785        }
786        (TargetTemplate::AppleContainer { .. }, TargetLocator::AppleContainer { container_id }) => {
787            hel_targets::TargetLocator::AppleContainer {
788                container_id: container_id.clone(),
789            }
790        }
791        (TargetTemplate::SshPodman { ssh, .. }, TargetLocator::SshPodman { container_id, .. }) => {
792            hel_targets::TargetLocator::SshPodman {
793                ssh: backend_ssh(ssh),
794                container_id: container_id.clone(),
795                workspace_storage: Default::default(),
796            }
797        }
798        (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
799            hel_targets::TargetLocator::SshBare {
800                ssh: backend_ssh(ssh),
801                workspace: workspace.to_string_lossy().into_owned(),
802            }
803        }
804        (
805            TargetTemplate::AwsEc2 {
806                aws_profile,
807                region,
808                ssh_user,
809                identity_file,
810                ssh_args,
811                ..
812            },
813            TargetLocator::AwsEc2 {
814                instance_id,
815                address,
816            },
817        ) => hel_targets::TargetLocator::AwsEc2 {
818            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
819            region: region.clone(),
820            instance_id: instance_id.clone(),
821            ssh: SshTarget {
822                destination: format!(
823                    "{ssh_user}@{}",
824                    address.as_deref().unwrap_or("unavailable.invalid")
825                ),
826                ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
827            },
828            workspace: format!(".local/share/hel/workspaces/{session_id}"),
829        },
830        _ => bail!("recovery target locator does not match target template"),
831    })
832}
833
834#[cfg(test)]
835mod tests {
836    use std::collections::BTreeMap;
837
838    use hel::hel_config::{
839        AwsAddressSource, ContainerTemplate as ConfigContainer, HarnessKind, HelConfig,
840        TargetTemplate,
841    };
842    use hel::hel_state::{HelState, TargetLocator};
843    use hel::hel_targets::ProcessExecutor;
844
845    use super::*;
846
847    const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
848
849    #[tokio::test]
850    async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
851        // MJ_DATA_DIR is process-global, so the database-backed half runs in
852        // an exact child test with its own data directory.
853        if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
854            let directory = tempfile::tempdir().unwrap();
855            let output = std::process::Command::new(std::env::current_exe().unwrap())
856                .args([
857                    "--exact",
858                    "hel_controller::recovery_scan::tests::\
859                     a_failed_adoption_records_the_failure_and_stays_retryable",
860                    "--nocapture",
861                ])
862                .env(FAILED_ADOPTION_CHILD, "1")
863                .env("MJ_DATA_DIR", directory.path())
864                .output()
865                .unwrap();
866            assert!(
867                output.status.success(),
868                "isolated adoption retry test failed\nstdout:\n{}\nstderr:\n{}",
869                String::from_utf8_lossy(&output.stdout),
870                String::from_utf8_lossy(&output.stderr)
871            );
872            return;
873        }
874        // Alone in this child process, so it installs the one writer.
875        let _writer = hel::hel_database::install_isolated_test_writer();
876
877        let session_id = "0123456789abcdef0123456789abcdef";
878        let workers = tempfile::tempdir().unwrap();
879        // Exactly what an adoption commits before its handshake, on a worker
880        // root that holds no worker binary, so the handshake cannot succeed.
881        let record = adopted_session_record(
882            session_id,
883            "local-bare",
884            "codex".into(),
885            HarnessKind::Codex,
886            "project".into(),
887            hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
888            TargetLocator::LocalBare {
889                worker_root: workers.path().join(session_id),
890            },
891        );
892        assert!(
893            adoption_unfinished(&record, "local-bare"),
894            "the record adoption commits must be the record adoption can retry"
895        );
896        hel::hel_database::save_session(&record).unwrap();
897        let mut config = HelConfig::default();
898        config
899            .targets
900            .insert("local-bare".into(), TargetTemplate::LocalBare);
901        let mut state = HelState::default();
902        state.sessions.insert(session_id.to_owned(), record);
903        let mut controller = Controller { config, state };
904
905        let failure = controller
906            .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
907            .await
908            .expect_err("a worker root without a worker cannot complete the handshake");
909        assert!(
910            format!("{failure:#}").contains("orphan relay"),
911            "unexpected failure: {failure:#}"
912        );
913        let recorded = controller.state.sessions[session_id]
914            .last_error
915            .clone()
916            .expect("the failed handshake was recorded on the session");
917        assert!(
918            recorded.contains("orphan adoption failed"),
919            "unexpected recorded failure: {recorded}"
920        );
921        assert_eq!(
922            controller.state.sessions[session_id].state,
923            SessionState::Disconnected
924        );
925        let stored = hel::hel_database::load_state().unwrap();
926        assert_eq!(
927            stored.sessions[session_id].last_error.as_deref(),
928            Some(recorded.as_str()),
929            "the adoption failure was not persisted"
930        );
931
932        let retry = controller
933            .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
934            .await
935            .expect_err("the worker is still unreachable");
936        let retry = format!("{retry:#}");
937        assert!(
938            retry.contains("orphan relay"),
939            "adoption did not retry the handshake: {retry}"
940        );
941        assert!(
942            !retry.contains("already tracked"),
943            "a session adoption never finished blocked its own retry: {retry}"
944        );
945    }
946
947    #[test]
948    fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
949        let mut record = adopted_session_record(
950            "0123456789abcdef0123456789abcdef",
951            "local-bare",
952            "codex".into(),
953            HarnessKind::Codex,
954            "project".into(),
955            hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
956            TargetLocator::LocalBare {
957                worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
958            },
959        );
960        record.native_session_id = Some("native-session".into());
961        assert!(!adoption_unfinished(&record, "local-bare"));
962
963        record.native_session_id = None;
964        assert!(
965            !adoption_unfinished(&record, "other-target"),
966            "a record adopted onto another target is not this target's retry"
967        );
968    }
969
970    #[test]
971    fn recovery_container_scan_requires_both_managed_and_session_labels() {
972        let template = TargetTemplate::LocalPodman {
973            container: ConfigContainer {
974                image: "ignored".into(),
975                pull_policy: Default::default(),
976                platform: None,
977                cpus: None,
978                memory: None,
979                environment: BTreeMap::new(),
980                workspace_storage: Default::default(),
981            },
982        };
983        let json = serde_json::json!([
984            {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef"}},
985            {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
986            {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
987        ]);
988        let candidates = candidates_from_container_json(
989            "local",
990            &template,
991            serde_json::to_string(&json).unwrap().as_bytes(),
992        )
993        .unwrap();
994        assert_eq!(candidates.len(), 2);
995        assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
996    }
997
998    #[test]
999    fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1000        let template = TargetTemplate::LocalDocker {
1001            container: ConfigContainer {
1002                image: "ignored".into(),
1003                pull_policy: Default::default(),
1004                platform: None,
1005                cpus: None,
1006                memory: None,
1007                environment: BTreeMap::new(),
1008                workspace_storage: Default::default(),
1009            },
1010        };
1011        let session = "0123456789abcdef0123456789abcdef";
1012        let output = format!(
1013            "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session}\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1014        );
1015
1016        let candidates =
1017            candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1018
1019        assert_eq!(candidates.len(), 1);
1020        assert_eq!(candidates[0].session_id, session);
1021        assert!(matches!(
1022            &candidates[0].locator,
1023            TargetLocator::LocalDocker { container_id }
1024                if container_id == &hel_targets::resource_name(session).unwrap()
1025        ));
1026    }
1027
1028    #[test]
1029    fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1030        let json = serde_json::json!({"Reservations": [{"Instances": [{
1031            "InstanceId": "i-exact",
1032            "PrivateIpAddress": "10.0.0.7",
1033            "Tags": [
1034                {"Key": "dev.mj.managed", "Value": "true"},
1035                {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"}
1036            ]
1037        }]}]});
1038        let candidates = candidates_from_aws_json(
1039            "aws",
1040            AwsAddressSource::PrivateIp,
1041            serde_json::to_string(&json).unwrap().as_bytes(),
1042        )
1043        .unwrap();
1044        assert!(matches!(
1045            &candidates[0].locator,
1046            TargetLocator::AwsEc2 { instance_id, address }
1047                if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1048        ));
1049    }
1050}