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