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