Skip to main content

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