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::{
10    PodmanWorkspaceLocator, SessionRecord, SessionState, TargetLocator, normalize_session_title,
11};
12
13use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
14use mj_core::worker_launch::WorkerOwnership;
15
16use super::backend::{ContainerOverrides, backend_locator, backend_target};
17use super::readiness::wait_for_native_session;
18use super::{Controller, now};
19
20pub use mj_core::state::{RecoveryCandidate, RecoveryScan};
21
22impl Controller {
23    /// Find managed resources which are not represented by the controller's
24    /// current state. Labels/tags establish Hel ownership; the worker marker
25    /// supplies profile and bundle metadata when it is available.
26    ///
27    /// Unless `all_instances` is set, only workers stamped with this
28    /// instance's identity are listed: a QA instance sharing a host with a
29    /// production instance must never see the production workers as its own.
30    pub fn scan_orphan_workers(
31        &self,
32        executor: &impl CommandExecutor,
33        all_instances: bool,
34    ) -> RecoveryScan {
35        let mut scan = RecoveryScan {
36            instance_id: mj_core::config::instance_identity(),
37            ..RecoveryScan::default()
38        };
39        for (target_id, template) in &self.config.targets {
40            match scan_target_workers(target_id, template, executor) {
41                Ok(candidates) => {
42                    for mut candidate in candidates {
43                        if self.state_represents(&candidate) {
44                            continue;
45                        }
46                        candidate.tracked_session = self
47                            .state
48                            .sessions
49                            .get(&candidate.session_id)
50                            .map(|record| record.state);
51                        scan.candidates.push(candidate);
52                    }
53                }
54                Err(error) => scan.warnings.push(format!("target {target_id}: {error:#}")),
55            }
56        }
57        scan.candidates.sort_by(|left, right| {
58            (&left.session_id, &left.target_template_id)
59                .cmp(&(&right.session_id, &right.target_template_id))
60        });
61        scan.candidates.dedup_by(|left, right| {
62            left.session_id == right.session_id
63                && left.target_template_id == right.target_template_id
64        });
65        if !all_instances {
66            restrict_to_instance(&mut scan);
67        }
68        scan
69    }
70
71    /// Whether the controller's own state still stands for this resource, so
72    /// recovery must leave it alone.
73    ///
74    /// A session stands for a resource only while some session record holds
75    /// the locator that names it; every record is checked, because a sub-agent
76    /// child borrows its parent's container and keeps the owner alive. Sharing
77    /// the session id is not enough: a failed provision and a failed move both
78    /// clear the locator and leave the session in `error`, and such a record
79    /// has no way of its own to reach the resource it left running.
80    ///
81    /// While the controller is provisioning or tearing a session down it is
82    /// writing that locator, so a record in one of those states is taken as
83    /// representing its resource whatever the locator says at this instant.
84    fn state_represents(&self, candidate: &RecoveryCandidate) -> bool {
85        if self
86            .state
87            .sessions
88            .get(&candidate.session_id)
89            .is_some_and(|record| locator_in_flight(record.state))
90        {
91            return true;
92        }
93        self.state.sessions.values().any(|record| {
94            record
95                .target
96                .as_ref()
97                .is_some_and(|locator| same_resource(locator, &candidate.locator))
98        })
99    }
100
101    pub async fn adopt_orphan_worker(
102        &mut self,
103        session_id: &str,
104        target_id: &str,
105        profile_override: Option<&str>,
106        bundle_override: Option<&str>,
107        all_instances: bool,
108        executor: &impl CommandExecutor,
109    ) -> Result<()> {
110        let (record, newly_adopted) = match self.state.sessions.get(session_id).cloned() {
111            // Adoption records its session before the handshake, so a failed
112            // handshake leaves a tracked session that never connected. That
113            // record is the one to finish, not a reason to refuse the retry.
114            Some(existing) if adoption_unfinished(&existing, target_id) => {
115                for (flag, requested, adopted) in [
116                    ("profile", profile_override, existing.last_profile.as_str()),
117                    ("bundle", bundle_override, existing.bundle_id.as_str()),
118                ] {
119                    if let Some(requested) = requested
120                        && requested != adopted
121                    {
122                        bail!(
123                            "session {session_id} was already adopted with {flag} {adopted:?}; retry without --{flag}"
124                        );
125                    }
126                }
127                (existing, false)
128            }
129            // A leftover resource whose session id is still taken cannot be
130            // adopted: the id would have to name two sessions. Destroying it
131            // is the whole of what recovery can offer for such a resource.
132            Some(existing) => bail!(
133                "session {session_id} is already tracked in state {}; use `recover destroy` to remove a resource it left behind",
134                existing.state.as_str()
135            ),
136            None => {
137                let scan = self.scan_orphan_workers(executor, true);
138                let candidate = scan
139                    .candidates
140                    .into_iter()
141                    .find(|candidate| {
142                        candidate.session_id == session_id
143                            && candidate.target_template_id == target_id
144                    })
145                    .with_context(|| {
146                        format!("no managed orphan {session_id} was found on target {target_id:?}")
147                    })?;
148                require_instance_access(&candidate, &scan.instance_id, all_instances)?;
149                let profile_id = profile_override
150                    .map(str::to_owned)
151                    .or_else(|| {
152                        candidate
153                            .ownership
154                            .as_ref()
155                            .map(|marker| marker.profile_id.clone())
156                    })
157                    .context("orphan has no ownership marker; pass --profile")?;
158                let bundle_id = bundle_override
159                    .map(str::to_owned)
160                    .or_else(|| {
161                        candidate
162                            .ownership
163                            .as_ref()
164                            .map(|marker| marker.bundle_id.clone())
165                    })
166                    .context("orphan has no ownership marker; pass --bundle")?;
167                let profile = self
168                    .config
169                    .profiles
170                    .get(&profile_id)
171                    .with_context(|| format!("unknown profile {profile_id:?}"))?;
172                self.config
173                    .bundles
174                    .get(&bundle_id)
175                    .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
176                let workspace_id = resolve_recovery_workspace_id(
177                    candidate
178                        .ownership
179                        .as_ref()
180                        .map(|ownership| ownership.workspace_id.as_str())
181                        .unwrap_or(mj_core::workspace::DEFAULT_WORKSPACE_ID),
182                )?;
183                let container_workspace = self
184                    .config
185                    .targets
186                    .get(target_id)
187                    .and_then(|template| {
188                        recovery_backend_locator(template, &candidate.locator, session_id).ok()
189                    })
190                    .and_then(|backend| {
191                        adopted_container_workspace(&backend, session_id, executor)
192                    });
193                let mut record = adopted_session_record(
194                    session_id,
195                    target_id,
196                    profile_id,
197                    profile.kind,
198                    bundle_id,
199                    workspace_id,
200                    candidate.locator,
201                );
202                record.container_workspace = container_workspace;
203                (record, true)
204            }
205        };
206        let locator = record
207            .target
208            .as_ref()
209            .context("adopted session has no target locator")?;
210        let backend = backend_locator(locator, &record, &self.config)?;
211        let spec = targets::reconnect_plan(&backend, session_id)?
212            .commands
213            .into_iter()
214            .next()
215            .context("reconnect plan is empty")?;
216        if newly_adopted {
217            // Adoption authors the whole record for a session Hel has never
218            // tracked, so it writes the whole row, and it writes it before the
219            // handshake: a crash in between must not orphan the worker again.
220            // The record reaches memory only once it is durable.
221            crate::database::save_session(&record)?;
222            self.state.sessions.insert(session_id.to_owned(), record);
223        }
224        match self.complete_adoption(session_id, &spec, executor).await {
225            Ok(()) => Ok(()),
226            // Provisioning leaves its failure on the session it failed for.
227            // Adoption owes the same: the record it already committed is all
228            // the user has to see why the worker never connected.
229            Err(error) => Err(self.record_adoption_failure(session_id, error)),
230        }
231    }
232
233    /// Connect the adopted worker's relay and promote the session to running.
234    async fn complete_adoption(
235        &mut self,
236        session_id: &str,
237        spec: &CommandSpec,
238        executor: &impl CommandExecutor,
239    ) -> Result<()> {
240        let mut relay = StandaloneSession::connect_command(spec, session_id)
241            .await
242            .context("orphan relay did not complete the v1 handshake")?;
243        let native_session_id = wait_for_native_session(&mut relay, executor).await?;
244        self.mark_worker_connected(session_id, Some(native_session_id))?;
245        if let Some(title) = relay
246            .snapshot()
247            .materialized
248            .session_title
249            .as_deref()
250            .and_then(normalize_session_title)
251        {
252            crate::database::set_session_acp_title(session_id, Some(&title))?;
253            self.state
254                .sessions
255                .get_mut(session_id)
256                .expect("adopted session disappeared while saving its ACP title")
257                .acp_session_title = Some(title);
258        }
259        Ok(())
260    }
261
262    /// Leave a failed adoption on the session itself. The state stays
263    /// `Disconnected`, which is the truth — the target exists and no worker is
264    /// connected — and keeps the record adoptable so the handshake can be
265    /// retried once the worker is reachable again.
266    fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
267        let Some(record) = self.state.sessions.get_mut(session_id) else {
268            return error;
269        };
270        record.updated_at = now();
271        record.last_error = Some(format!("orphan adoption failed: {error:#}"));
272        match self.persist_session_state(session_id) {
273            Ok(()) => error,
274            Err(persist_error) => error.context(format!(
275                "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
276            )),
277        }
278    }
279
280    pub fn destroy_orphan_worker(
281        &self,
282        session_id: &str,
283        target_id: &str,
284        confirmation: &str,
285        all_instances: bool,
286        executor: &impl CommandExecutor,
287    ) -> Result<()> {
288        if confirmation != session_id {
289            bail!("refusing destructive recovery: --confirm must exactly match the session ID");
290        }
291        let scan = self.scan_orphan_workers(executor, true);
292        let candidate = scan
293            .candidates
294            .into_iter()
295            .find(|candidate| {
296                candidate.session_id == session_id && candidate.target_template_id == target_id
297            })
298            .with_context(|| {
299                format!("no managed orphan {session_id} was found on target {target_id:?}")
300            })?;
301        require_instance_access(&candidate, &scan.instance_id, all_instances)?;
302        let template = self.config.targets.get(target_id).unwrap();
303        let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
304        targets::close_plan(&backend, session_id)?
305            .execute(executor)
306            .map(|_| ())
307    }
308}
309
310/// States in which the controller is itself writing the session's locator.
311/// The record cannot be read as settled evidence about its resource then.
312const fn locator_in_flight(state: SessionState) -> bool {
313    matches!(
314        state,
315        SessionState::Provisioning
316            | SessionState::Checkpointing
317            | SessionState::Closing
318            | SessionState::Destroying
319    )
320}
321
322/// Whether two locators name the same managed resource. Only the identity
323/// fields count: borrowing, workspace storage, and a discovered address say
324/// how a session reaches the resource, not which resource it is.
325fn same_resource(left: &TargetLocator, right: &TargetLocator) -> bool {
326    match (left, right) {
327        (
328            TargetLocator::LocalBare { worker_root: left },
329            TargetLocator::LocalBare { worker_root: right },
330        ) => left == right,
331        (
332            TargetLocator::LocalPodman {
333                container_id: left, ..
334            },
335            TargetLocator::LocalPodman {
336                container_id: right,
337                ..
338            },
339        )
340        | (
341            TargetLocator::LocalDocker {
342                container_id: left, ..
343            },
344            TargetLocator::LocalDocker {
345                container_id: right,
346                ..
347            },
348        )
349        | (
350            TargetLocator::AppleContainer {
351                container_id: left, ..
352            },
353            TargetLocator::AppleContainer {
354                container_id: right,
355                ..
356            },
357        ) => left == right,
358        (
359            TargetLocator::AwsEc2 {
360                instance_id: left, ..
361            },
362            TargetLocator::AwsEc2 {
363                instance_id: right, ..
364            },
365        ) => left == right,
366        (
367            TargetLocator::SshBare {
368                host: left_host,
369                workspace: left_workspace,
370                ..
371            },
372            TargetLocator::SshBare {
373                host: right_host,
374                workspace: right_workspace,
375                ..
376            },
377        ) => left_host == right_host && left_workspace == right_workspace,
378        (
379            TargetLocator::SshPodman {
380                host: left_host,
381                container_id: left_container,
382                ..
383            },
384            TargetLocator::SshPodman {
385                host: right_host,
386                container_id: right_container,
387                ..
388            },
389        )
390        | (
391            TargetLocator::SshDocker {
392                host: left_host,
393                container_id: left_container,
394                ..
395            },
396            TargetLocator::SshDocker {
397                host: right_host,
398                container_id: right_container,
399                ..
400            },
401        ) => left_host == right_host && left_container == right_container,
402        _ => false,
403    }
404}
405
406/// Drop candidates another or an unknown instance created, counting them so
407/// the user learns that `--all-instances` would show more.
408fn restrict_to_instance(scan: &mut RecoveryScan) {
409    let before = scan.candidates.len();
410    scan.candidates
411        .retain(|candidate| candidate.instance_id.as_deref() == Some(scan.instance_id.as_str()));
412    scan.hidden_other_instances = before - scan.candidates.len();
413}
414
415/// Refuse to act on a worker that another instance created, or whose
416/// instance is unknown, unless the caller widened the scope explicitly.
417fn require_instance_access(
418    candidate: &RecoveryCandidate,
419    scan_instance: &str,
420    all_instances: bool,
421) -> Result<()> {
422    if all_instances {
423        return Ok(());
424    }
425    match candidate.instance_id.as_deref() {
426        Some(instance) if instance == scan_instance => Ok(()),
427        Some(other) => bail!(
428            "worker {} belongs to instance {other:?}, not this instance {scan_instance:?}; pass --all-instances to act on it",
429            candidate.session_id
430        ),
431        None => bail!(
432            "worker {} has no instance stamp (created by an older build); pass --all-instances to act on it",
433            candidate.session_id
434        ),
435    }
436}
437
438/// Worker markers can outlive the controller database that created them. Keep
439/// a workspace marker only when this controller knows its identity; otherwise
440/// group the adopted worker under one durable recovery workspace.
441fn resolve_recovery_workspace_id(marked_workspace_id: &str) -> Result<String> {
442    if marked_workspace_id == mj_core::workspace::DEFAULT_WORKSPACE_ID {
443        return Ok(marked_workspace_id.to_owned());
444    }
445    if crate::database::list_workspaces()?
446        .into_iter()
447        .any(|workspace| workspace.id == marked_workspace_id)
448    {
449        return Ok(marked_workspace_id.to_owned());
450    }
451    Ok(crate::database::create_or_get_workspace("Recovered")?.id)
452}
453
454/// The workspace a running container actually holds. A container created
455/// before per-session workspaces has only the shared `/workspace`, so an
456/// adoption that cannot see `/workspace/<session id>` keeps the legacy path
457/// rather than pointing the recovered harness at a directory that is not there.
458fn adopted_container_workspace(
459    backend: &targets::TargetLocator,
460    session_id: &str,
461    executor: &impl CommandExecutor,
462) -> Option<PathBuf> {
463    if !matches!(
464        backend,
465        targets::TargetLocator::LocalPodman { .. }
466            | targets::TargetLocator::LocalDocker { .. }
467            | targets::TargetLocator::AppleContainer { .. }
468            | targets::TargetLocator::SshPodman { .. }
469            | targets::TargetLocator::SshDocker { .. }
470    ) {
471        return None;
472    }
473    let workspace = targets::new_container_workspace(session_id).ok()?;
474    let command = targets::command_on_locator(
475        backend,
476        session_id,
477        vec![
478            "test".to_owned(),
479            "-d".to_owned(),
480            workspace.to_string_lossy().into_owned(),
481        ],
482        "probe the adopted session workspace",
483    )
484    .ok()?;
485    match executor.execute(&command) {
486        Ok(output) if output.status == 0 => Some(workspace),
487        Ok(_) => None,
488        Err(error) => {
489            tracing::debug!(
490                session_id,
491                %error,
492                "could not probe the adopted session workspace; assuming the shared one"
493            );
494            None
495        }
496    }
497}
498
499/// The session record adoption commits before it tries the relay handshake.
500fn adopted_session_record(
501    session_id: &str,
502    target_id: &str,
503    profile_id: String,
504    harness_kind: mj_core::config::HarnessKind,
505    bundle_id: String,
506    workspace_id: String,
507    locator: TargetLocator,
508) -> SessionRecord {
509    let now = now();
510    SessionRecord {
511        build_cache: None,
512        mjolnir_subagents: None,
513        // The adopting caller probes the running container for this.
514        container_workspace: None,
515        create_managed_worktree: None,
516        workspace_id,
517        archived: false,
518        container_cpus: None,
519        container_memory: None,
520        id: session_id.to_owned(),
521        title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
522        harness_kind,
523        last_profile: profile_id,
524        bundle_id,
525        project_directory: None,
526        managed_worktree: None,
527        target_template_id: target_id.to_owned(),
528        resource_allocation: None,
529        additional_mounts: Vec::new(),
530        state: SessionState::Disconnected,
531        target: Some(locator),
532        native_session_id: None,
533        acp_session_title: None,
534        session_title_override: None,
535        created_at: now.clone(),
536        updated_at: now,
537        viewed_through_event_ordinal: 0,
538        draft_input: String::new(),
539        last_error: None,
540        last_checkpoint_error: None,
541        checkpoint: None,
542    }
543}
544
545/// Whether a tracked session is one an adoption committed and never finished:
546/// it names this target, carries the locator the scan found, and no harness
547/// session has ever been observed on it. Such a record is the retry, so
548/// adoption completes it instead of refusing it as already tracked.
549fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
550    record.state == SessionState::Disconnected
551        && record.native_session_id.is_none()
552        && record.target_template_id == target_id
553        && record.target.is_some()
554}
555
556fn scan_target_workers(
557    target_id: &str,
558    template: &TargetTemplate,
559    executor: &impl CommandExecutor,
560) -> Result<Vec<RecoveryCandidate>> {
561    let mut candidates = match template {
562        // Local bare sessions persist their locator in the controller database.
563        // Do not infer an adoptable project from Hel's transient worker directory.
564        TargetTemplate::LocalBare => Vec::new(),
565        TargetTemplate::LocalPodman { .. } => scan_container_engine(
566            target_id,
567            template,
568            "podman",
569            vec![
570                "ps".into(),
571                "--all".into(),
572                "--filter".into(),
573                format!("label={}=true", targets::MANAGED_LABEL),
574                "--format".into(),
575                "json".into(),
576            ],
577            executor,
578        )?,
579        TargetTemplate::LocalDocker { .. } => scan_container_engine(
580            target_id,
581            template,
582            "docker",
583            vec![
584                "ps".into(),
585                "--all".into(),
586                "--filter".into(),
587                format!("label={}=true", targets::MANAGED_LABEL),
588                "--format".into(),
589                "json".into(),
590            ],
591            executor,
592        )?,
593        TargetTemplate::AppleContainer { .. } => scan_container_engine(
594            target_id,
595            template,
596            "container",
597            vec![
598                "list".into(),
599                "--all".into(),
600                "--format".into(),
601                "json".into(),
602            ],
603            executor,
604        )?,
605        TargetTemplate::SshPodman { ssh, .. } => {
606            let remote = targets::join_remote_command(&[
607                "podman".into(),
608                "ps".into(),
609                "--all".into(),
610                "--filter".into(),
611                format!("label={}=true", targets::MANAGED_LABEL),
612                "--format".into(),
613                "json".into(),
614            ]);
615            let output = execute_scan(
616                executor,
617                ssh_spec(ssh, [remote]),
618                "scan remote Podman workers",
619            )?;
620            candidates_from_container_json(target_id, template, &output.stdout)?
621        }
622        TargetTemplate::SshDocker { ssh, .. } => {
623            let remote = targets::join_remote_command(&[
624                "docker".into(),
625                "ps".into(),
626                "--all".into(),
627                "--filter".into(),
628                format!("label={}=true", targets::MANAGED_LABEL),
629                "--format".into(),
630                "json".into(),
631            ]);
632            let output = execute_scan(
633                executor,
634                ssh_spec(ssh, [remote]),
635                "scan remote Docker workers",
636            )?;
637            candidates_from_container_json(target_id, template, &output.stdout)?
638        }
639        TargetTemplate::AwsEc2 {
640            aws_profile,
641            region,
642            address_source,
643            ..
644        } => {
645            let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
646            let output = execute_scan(
647                executor,
648                CommandSpec::new(
649                    "aws",
650                    [
651                        "--profile".into(),
652                        profile,
653                        "--region".into(),
654                        region.clone(),
655                        "ec2".into(),
656                        "describe-instances".into(),
657                        "--filters".into(),
658                        format!("Name=tag:{},Values=true", targets::MANAGED_TAG),
659                        "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
660                        "--output".into(),
661                        "json".into(),
662                    ],
663                )
664                .purpose("scan managed EC2 workers"),
665                "scan managed EC2 workers",
666            )?;
667            candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
668        }
669        TargetTemplate::SshBare { ssh, .. } => {
670            let output = execute_scan(
671                executor,
672                ssh_spec(
673                    ssh,
674                    [targets::join_remote_command(&[
675                        "find".into(),
676                        ".local/share/hel/workers".into(),
677                        "-mindepth".into(),
678                        "2".into(),
679                        "-maxdepth".into(),
680                        "2".into(),
681                        "-name".into(),
682                        "ownership.json".into(),
683                        "-print".into(),
684                    ])],
685                ),
686                "scan bare SSH worker markers",
687            )?;
688            output
689                .stdout
690                .split(|byte| *byte == b'\n')
691                .filter_map(|line| {
692                    let path = match std::str::from_utf8(line) {
693                        Ok(path) => path.trim(),
694                        Err(error) => {
695                            tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
696                            return None;
697                        }
698                    };
699                    let Some(session_id) = Path::new(path)
700                        .parent()
701                        .and_then(|parent| parent.file_name())
702                        .and_then(|name| name.to_str())
703                    else {
704                        tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
705                        return None;
706                    };
707                    if let Err(error) = targets::resource_name(session_id) {
708                        tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
709                        return None;
710                    }
711                    let backend = match backend_target(template, None, ContainerOverrides::default()) {
712                        Ok(backend) => backend,
713                        Err(error) => {
714                            tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
715                            return None;
716                        }
717                    };
718                    let workspace = match targets::workspace_for(&backend, session_id) {
719                        Ok(workspace) => workspace,
720                        Err(error) => {
721                            tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
722                            return None;
723                        }
724                    };
725                    Some(RecoveryCandidate {
726                        session_id: session_id.to_owned(),
727                        target_template_id: target_id.to_owned(),
728                        locator: TargetLocator::SshBare {
729                            host: ssh.host.clone(),
730                            workspace: PathBuf::from(workspace),
731                            // The scan reads the session id from the worker
732                            // directory name and `targets::worker_root` falls
733                            // back to it, so the root still resolves.
734                            worker_id: None,
735                        },
736                        ownership: None,
737                        instance_id: None,
738                        tracked_session: None,
739                    })
740                })
741                .collect()
742        }
743    };
744    for candidate in &mut candidates {
745        candidate.ownership = read_recovery_ownership(template, candidate, executor);
746        // The label is authoritative; the marker only fills in when the
747        // resource carries no stamp (bare SSH workers have no labels at all).
748        if candidate.instance_id.is_none() {
749            candidate.instance_id = candidate
750                .ownership
751                .as_ref()
752                .and_then(|marker| marker.instance_id.clone());
753        }
754    }
755    Ok(candidates)
756}
757
758fn scan_container_engine(
759    target_id: &str,
760    template: &TargetTemplate,
761    engine: &str,
762    args: Vec<String>,
763    executor: &impl CommandExecutor,
764) -> Result<Vec<RecoveryCandidate>> {
765    let output = execute_scan(
766        executor,
767        CommandSpec::new(engine, args).purpose("scan managed container workers"),
768        "scan managed container workers",
769    )?;
770    candidates_from_container_json(target_id, template, &output.stdout)
771}
772
773/// Where a Podman session's workspace really lives, derived the same way
774/// provisioning derives it: the backend container template plus the session id.
775fn recovery_workspace_storage(
776    template: &TargetTemplate,
777    session_id: &str,
778) -> Result<PodmanWorkspaceLocator> {
779    let backend = backend_target(template, None, ContainerOverrides::default())?;
780    let container = match &backend {
781        targets::TargetTemplate::LocalPodman(container) => container,
782        targets::TargetTemplate::SshPodman { container, .. } => container,
783        _ => bail!("target template is not a Podman target"),
784    };
785    Ok(PodmanWorkspaceLocator::from(
786        targets::podman_workspace_locator(container, session_id)?,
787    ))
788}
789
790fn candidates_from_container_json(
791    target_id: &str,
792    template: &TargetTemplate,
793    stdout: &[u8],
794) -> Result<Vec<RecoveryCandidate>> {
795    let sessions = managed_sessions_from_container_json(stdout)?;
796    Ok(sessions
797        .into_iter()
798        .filter_map(|(session_id, instance_id)| {
799            let generated = match targets::resource_name(&session_id) {
800                Ok(generated) => generated,
801                Err(error) => {
802                    tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
803                    return None;
804                }
805            };
806            // A Podman workspace may live in a volume or a host directory, and
807            // destroying the container alone would leave it behind. The storage
808            // follows from the target template and the session id, so derive it
809            // here rather than recording the container layer by default.
810            let workspace_storage = match template {
811                TargetTemplate::LocalPodman { .. } | TargetTemplate::SshPodman { .. } => {
812                    match recovery_workspace_storage(template, &session_id) {
813                        Ok(storage) => storage,
814                        Err(error) => {
815                            tracing::debug!(%session_id, %error, "recovery scan could not derive the Podman workspace storage");
816                            return None;
817                        }
818                    }
819                }
820                _ => Default::default(),
821            };
822            let locator = match template {
823                TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
824                    borrowed_from: None,
825                    container_id: generated,
826                    workspace_storage,
827                },
828                TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
829                    borrowed_from: None,
830                    container_id: generated,
831                },
832                TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
833                    borrowed_from: None,
834                    container_id: generated,
835                },
836                TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
837                    borrowed_from: None,
838                    host: ssh.host.clone(),
839                    container_id: generated,
840                    workspace_storage,
841                },
842                TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
843                    borrowed_from: None,
844                    host: ssh.host.clone(),
845                    container_id: generated,
846                },
847                _ => return None,
848            };
849            Some(RecoveryCandidate {
850                session_id,
851                target_template_id: target_id.to_owned(),
852                locator,
853                ownership: None,
854                instance_id,
855                tracked_session: None,
856            })
857        })
858        .collect())
859}
860
861/// Managed session IDs in a container listing, each with the instance label
862/// that created it when the container carries one.
863pub(super) fn managed_sessions_from_container_json(
864    stdout: &[u8],
865) -> Result<Vec<(String, Option<String>)>> {
866    let values = serde_json::Deserializer::from_slice(stdout)
867        .into_iter::<serde_json::Value>()
868        .collect::<std::result::Result<Vec<_>, _>>()
869        .context("parse container list JSON")?;
870    let mut sessions = Vec::new();
871    for value in &values {
872        collect_managed_sessions(value, &mut sessions);
873    }
874    sessions.sort();
875    sessions.dedup();
876    Ok(sessions)
877}
878
879pub(super) fn collect_managed_sessions(
880    value: &serde_json::Value,
881    sessions: &mut Vec<(String, Option<String>)>,
882) {
883    match value {
884        serde_json::Value::Array(values) => {
885            for value in values {
886                collect_managed_sessions(value, sessions);
887            }
888        }
889        serde_json::Value::Object(object) => {
890            for label_key in ["Labels", "labels"] {
891                if let Some(labels) = object.get(label_key) {
892                    let managed = label_value(labels, targets::MANAGED_LABEL)
893                        .is_some_and(|value| value == "true");
894                    if managed && let Some(session) = label_value(labels, targets::SESSION_LABEL) {
895                        sessions.push((session, label_value(labels, targets::INSTANCE_LABEL)));
896                    }
897                }
898            }
899            for value in object.values() {
900                collect_managed_sessions(value, sessions);
901            }
902        }
903        _ => {}
904    }
905}
906
907fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
908    match labels {
909        serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
910        serde_json::Value::String(text) => text
911            .split(',')
912            .find_map(|label| {
913                label
914                    .trim()
915                    .split_once('=')
916                    .filter(|(name, _)| *name == key)
917            })
918            .map(|(_, value)| value.to_owned()),
919        _ => None,
920    }
921}
922
923fn candidates_from_aws_json(
924    target_id: &str,
925    address_source: AwsAddressSource,
926    stdout: &[u8],
927) -> Result<Vec<RecoveryCandidate>> {
928    let value: serde_json::Value =
929        serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
930    let mut result = Vec::new();
931    let reservations = value
932        .get("Reservations")
933        .and_then(serde_json::Value::as_array)
934        .cloned()
935        .unwrap_or_default();
936    for instance in reservations.iter().flat_map(|reservation| {
937        reservation
938            .get("Instances")
939            .and_then(serde_json::Value::as_array)
940            .into_iter()
941            .flatten()
942    }) {
943        let tags = instance
944            .get("Tags")
945            .and_then(serde_json::Value::as_array)
946            .cloned()
947            .unwrap_or_default();
948        let tag = |key: &str| {
949            tags.iter()
950                .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
951                .and_then(|tag| tag.get("Value"))
952                .and_then(serde_json::Value::as_str)
953        };
954        if tag(targets::MANAGED_TAG) != Some("true") {
955            continue;
956        }
957        let Some(session_id) = tag(targets::SESSION_TAG).map(str::to_owned) else {
958            continue;
959        };
960        targets::resource_name(&session_id)?;
961        let instance_id = instance
962            .get("InstanceId")
963            .and_then(serde_json::Value::as_str)
964            .context("managed EC2 instance omitted InstanceId")?
965            .to_owned();
966        let field = match address_source {
967            AwsAddressSource::PublicDns => "PublicDnsName",
968            AwsAddressSource::PublicIp => "PublicIpAddress",
969            AwsAddressSource::PrivateDns => "PrivateDnsName",
970            AwsAddressSource::PrivateIp => "PrivateIpAddress",
971        };
972        let address = instance
973            .get(field)
974            .and_then(serde_json::Value::as_str)
975            .filter(|value| !value.is_empty())
976            .map(str::to_owned);
977        let created_by = tag(targets::INSTANCE_TAG).map(str::to_owned);
978        result.push(RecoveryCandidate {
979            session_id,
980            target_template_id: target_id.to_owned(),
981            locator: TargetLocator::AwsEc2 {
982                instance_id,
983                address,
984            },
985            ownership: None,
986            instance_id: created_by,
987            tracked_session: None,
988        });
989    }
990    Ok(result)
991}
992
993fn execute_scan(
994    executor: &impl CommandExecutor,
995    command: CommandSpec,
996    operation: &str,
997) -> Result<CommandOutput> {
998    let output = executor.execute(&command)?;
999    if output.status != 0 {
1000        bail!(
1001            "{operation} failed with status {}: {}",
1002            output.status,
1003            String::from_utf8_lossy(&output.stderr).trim()
1004        );
1005    }
1006    Ok(output)
1007}
1008
1009fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
1010    let backend = SshTarget::from(ssh);
1011    let mut args = backend.ssh_args;
1012    mj_core::targets::push_connection_sharing_args(&mut args);
1013    args.push(backend.destination.clone());
1014    args.extend(remote);
1015    CommandSpec::new("ssh", args).ssh_destination(backend.destination)
1016}
1017
1018fn read_recovery_ownership(
1019    template: &TargetTemplate,
1020    candidate: &RecoveryCandidate,
1021    executor: &impl CommandExecutor,
1022) -> Option<WorkerOwnership> {
1023    let backend =
1024        match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
1025            Ok(backend) => backend,
1026            Err(error) => {
1027                tracing::debug!(
1028                    session_id = %candidate.session_id,
1029                    %error,
1030                    "could not construct a recovery ownership probe"
1031                );
1032                return None;
1033            }
1034        };
1035    let root = match targets::worker_root(&backend, &candidate.session_id) {
1036        Ok(root) => root,
1037        Err(error) => {
1038            tracing::debug!(
1039                session_id = %candidate.session_id,
1040                %error,
1041                "could not derive a recovery worker root"
1042            );
1043            return None;
1044        }
1045    };
1046    let command = match targets::command_on_locator(
1047        &backend,
1048        &candidate.session_id,
1049        vec!["cat".into(), format!("{root}/ownership.json")],
1050        "read worker ownership marker",
1051    ) {
1052        Ok(command) => command,
1053        Err(error) => {
1054            tracing::debug!(
1055                session_id = %candidate.session_id,
1056                %error,
1057                "could not construct a recovery ownership command"
1058            );
1059            return None;
1060        }
1061    };
1062    let output = match executor.execute(&command) {
1063        Ok(output) => output,
1064        Err(error) => {
1065            tracing::debug!(
1066                session_id = %candidate.session_id,
1067                %error,
1068                "could not read a recovery worker ownership marker"
1069            );
1070            return None;
1071        }
1072    };
1073    if output.status != 0 {
1074        tracing::debug!(
1075            session_id = %candidate.session_id,
1076            status = output.status,
1077            "recovery worker ownership probe returned a failure"
1078        );
1079        return None;
1080    }
1081    let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
1082        Ok(marker) => marker,
1083        Err(error) => {
1084            tracing::debug!(
1085                session_id = %candidate.session_id,
1086                %error,
1087                "recovery worker ownership marker was not valid JSON"
1088            );
1089            return None;
1090        }
1091    };
1092    if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
1093        || marker.session_id != candidate.session_id
1094        || marker.target_template_id != candidate.target_template_id
1095    {
1096        tracing::debug!(
1097            session_id = %candidate.session_id,
1098            marker_session_id = %marker.session_id,
1099            marker_target_template_id = %marker.target_template_id,
1100            "recovery worker ownership marker did not match the candidate"
1101        );
1102        return None;
1103    }
1104    Some(marker)
1105}
1106
1107/// The backend locator recovery uses to destroy an orphan worker.
1108///
1109/// This stays separate from `backend_locator` (and from the shared
1110/// `TryFrom<StoredTarget>` conversion) because destroying an orphan has to
1111/// succeed in two cases the shared conversion rejects or cannot answer:
1112///
1113/// - The AWS arm substitutes `unavailable.invalid` for a missing address on
1114///   purpose. AWS cleanup terminates the instance through the `aws` CLI
1115///   (`targets/cleanup.rs`), so an instance with no address must still be
1116///   destroyable, whereas the shared conversion errors on the missing address.
1117/// - `worker_id: None` for `SshBare` is correct here. The scan takes the
1118///   session id from the worker directory name and `targets::worker_root`
1119///   falls back to the session id, so the root resolves. A borrowed target's
1120///   shared parent workspace is not discoverable from a scan, and recomputing
1121///   the child's own (nonexistent) path is the safe direction for destroy.
1122fn recovery_backend_locator(
1123    template: &TargetTemplate,
1124    locator: &TargetLocator,
1125    session_id: &str,
1126) -> Result<targets::TargetLocator> {
1127    Ok(match (template, locator) {
1128        (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
1129            targets::TargetLocator::LocalBare {
1130                worker_root: worker_root.to_string_lossy().into_owned(),
1131            }
1132        }
1133        (
1134            TargetTemplate::LocalPodman { .. },
1135            TargetLocator::LocalPodman {
1136                container_id,
1137                workspace_storage,
1138                ..
1139            },
1140        ) => targets::TargetLocator::LocalPodman {
1141            borrowed_from: None,
1142            container_id: container_id.clone(),
1143            workspace_storage: workspace_storage.into(),
1144        },
1145        (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id, .. }) => {
1146            targets::TargetLocator::LocalDocker {
1147                borrowed_from: None,
1148                container_id: container_id.clone(),
1149            }
1150        }
1151        (
1152            TargetTemplate::AppleContainer { .. },
1153            TargetLocator::AppleContainer { container_id, .. },
1154        ) => targets::TargetLocator::AppleContainer {
1155            borrowed_from: None,
1156            container_id: container_id.clone(),
1157        },
1158        (
1159            TargetTemplate::SshPodman { ssh, .. },
1160            TargetLocator::SshPodman {
1161                container_id,
1162                workspace_storage,
1163                ..
1164            },
1165        ) => targets::TargetLocator::SshPodman {
1166            borrowed_from: None,
1167            ssh: SshTarget::from(ssh),
1168            container_id: container_id.clone(),
1169            workspace_storage: workspace_storage.into(),
1170        },
1171        (
1172            TargetTemplate::SshDocker { ssh, .. },
1173            TargetLocator::SshDocker {
1174                host, container_id, ..
1175            },
1176        ) => {
1177            if host != &ssh.host {
1178                bail!("recovery SSH Docker host does not match target template")
1179            }
1180            targets::TargetLocator::SshDocker {
1181                borrowed_from: None,
1182                ssh: SshTarget::from(ssh),
1183                container_id: container_id.clone(),
1184            }
1185        }
1186        (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
1187            targets::TargetLocator::SshBare {
1188                ssh: SshTarget::from(ssh),
1189                workspace: workspace.to_string_lossy().into_owned(),
1190                worker_id: None,
1191            }
1192        }
1193        (
1194            TargetTemplate::AwsEc2 {
1195                aws_profile,
1196                region,
1197                ssh_user,
1198                identity_file,
1199                ssh_args,
1200                ..
1201            },
1202            TargetLocator::AwsEc2 {
1203                instance_id,
1204                address,
1205            },
1206        ) => targets::TargetLocator::AwsEc2 {
1207            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
1208            region: region.clone(),
1209            instance_id: instance_id.clone(),
1210            ssh: SshTarget {
1211                destination: format!(
1212                    "{ssh_user}@{}",
1213                    address.as_deref().unwrap_or("unavailable.invalid")
1214                ),
1215                ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1216            },
1217            workspace: format!(".local/share/hel/workspaces/{session_id}"),
1218        },
1219        _ => bail!("recovery target locator does not match target template"),
1220    })
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use std::collections::BTreeMap;
1226
1227    use crate::controller::test_support::{IsolatedTest, test_name};
1228    use mj_core::config::{
1229        AwsAddressSource, Config, ContainerTemplate as ConfigContainer, HarnessKind,
1230        PodmanWorkspaceStorage, TargetTemplate,
1231    };
1232    use mj_core::state::{State, TargetLocator};
1233
1234    use crate::targets::ProcessExecutor;
1235
1236    use super::*;
1237
1238    const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
1239
1240    #[tokio::test]
1241    async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
1242        // MJ_DATA_DIR is process-global, so the database-backed half runs in
1243        // an exact child test with its own data directory.
1244        if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
1245            let directory = tempfile::tempdir().unwrap();
1246            IsolatedTest::new(test_name(
1247                module_path!(),
1248                "a_failed_adoption_records_the_failure_and_stays_retryable",
1249            ))
1250            .env(FAILED_ADOPTION_CHILD, "1")
1251            .env("MJ_DATA_DIR", directory.path())
1252            .run();
1253            return;
1254        }
1255        // Alone in this child process, so it installs the one writer.
1256        let _writer = crate::database::install_isolated_test_writer();
1257
1258        let session_id = "0123456789abcdef0123456789abcdef";
1259        let workers = tempfile::tempdir().unwrap();
1260        // Exactly what an adoption commits before its handshake, on a worker
1261        // root that holds no worker binary, so the handshake cannot succeed.
1262        let record = adopted_session_record(
1263            session_id,
1264            "local-bare",
1265            "codex".into(),
1266            HarnessKind::Codex,
1267            "project".into(),
1268            mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1269            TargetLocator::LocalBare {
1270                worker_root: workers.path().join(session_id),
1271            },
1272        );
1273        assert!(
1274            adoption_unfinished(&record, "local-bare"),
1275            "the record adoption commits must be the record adoption can retry"
1276        );
1277        crate::database::save_session(&record).unwrap();
1278        let mut config = Config::default();
1279        config
1280            .targets
1281            .insert("local-bare".into(), TargetTemplate::LocalBare);
1282        let mut state = State::default();
1283        state.sessions.insert(session_id.to_owned(), record);
1284        let mut controller = Controller { config, state };
1285
1286        let failure = controller
1287            .adopt_orphan_worker(
1288                session_id,
1289                "local-bare",
1290                None,
1291                None,
1292                false,
1293                &ProcessExecutor,
1294            )
1295            .await
1296            .expect_err("a worker root without a worker cannot complete the handshake");
1297        assert!(
1298            format!("{failure:#}").contains("orphan relay"),
1299            "unexpected failure: {failure:#}"
1300        );
1301        let recorded = controller.state.sessions[session_id]
1302            .last_error
1303            .clone()
1304            .expect("the failed handshake was recorded on the session");
1305        assert!(
1306            recorded.contains("orphan adoption failed"),
1307            "unexpected recorded failure: {recorded}"
1308        );
1309        assert_eq!(
1310            controller.state.sessions[session_id].state,
1311            SessionState::Disconnected
1312        );
1313        let stored = crate::database::load_state().unwrap();
1314        assert_eq!(
1315            stored.sessions[session_id].last_error.as_deref(),
1316            Some(recorded.as_str()),
1317            "the adoption failure was not persisted"
1318        );
1319
1320        let retry = controller
1321            .adopt_orphan_worker(
1322                session_id,
1323                "local-bare",
1324                None,
1325                None,
1326                false,
1327                &ProcessExecutor,
1328            )
1329            .await
1330            .expect_err("the worker is still unreachable");
1331        let retry = format!("{retry:#}");
1332        assert!(
1333            retry.contains("orphan relay"),
1334            "adoption did not retry the handshake: {retry}"
1335        );
1336        assert!(
1337            !retry.contains("already tracked"),
1338            "a session adoption never finished blocked its own retry: {retry}"
1339        );
1340    }
1341
1342    const RECOVERY_WORKSPACE_CHILD: &str = "MJ_TEST_RECOVERY_WORKSPACE_CHILD";
1343
1344    #[tokio::test]
1345    async fn orphan_workspace_ids_are_reconciled_before_adoption_persistence() {
1346        if std::env::var_os(RECOVERY_WORKSPACE_CHILD).is_none() {
1347            let directory = tempfile::tempdir().unwrap();
1348            IsolatedTest::new(test_name(
1349                module_path!(),
1350                "orphan_workspace_ids_are_reconciled_before_adoption_persistence",
1351            ))
1352            .env(RECOVERY_WORKSPACE_CHILD, "1")
1353            .env("MJ_DATA_DIR", directory.path())
1354            .run();
1355            return;
1356        }
1357
1358        let _writer = crate::database::install_isolated_test_writer();
1359        let default =
1360            resolve_recovery_workspace_id(mj_core::workspace::DEFAULT_WORKSPACE_ID).unwrap();
1361        assert_eq!(default, mj_core::workspace::DEFAULT_WORKSPACE_ID);
1362
1363        let known = crate::database::create_or_get_workspace("Known").unwrap();
1364        assert_eq!(resolve_recovery_workspace_id(&known.id).unwrap(), known.id);
1365
1366        let recovered = resolve_recovery_workspace_id("workspace-from-old-controller").unwrap();
1367        let repeated = resolve_recovery_workspace_id("another-old-workspace").unwrap();
1368        assert_eq!(repeated, recovered);
1369        assert_eq!(
1370            crate::database::list_workspaces()
1371                .unwrap()
1372                .iter()
1373                .filter(|workspace| workspace.name == "Recovered")
1374                .count(),
1375            1
1376        );
1377
1378        let session_id = "0123456789abcdef0123456789abcdef";
1379        let workers = tempfile::tempdir().unwrap();
1380        let record = adopted_session_record(
1381            session_id,
1382            "local-bare",
1383            "codex".into(),
1384            HarnessKind::Codex,
1385            "project".into(),
1386            recovered.clone(),
1387            TargetLocator::LocalBare {
1388                worker_root: workers.path().join(session_id),
1389            },
1390        );
1391        crate::database::save_session(&record).unwrap();
1392        assert_eq!(
1393            crate::database::load_state().unwrap().sessions[session_id].workspace_id,
1394            recovered
1395        );
1396
1397        // Adoption has already committed the reconciled workspace. Exercise
1398        // the post-save handshake path with the same fake used by the retry
1399        // regression, which leaves and persists a diagnostic on failure.
1400        let mut state = State::default();
1401        state.sessions.insert(session_id.to_owned(), record);
1402        let mut config = Config::default();
1403        config
1404            .targets
1405            .insert("local-bare".into(), TargetTemplate::LocalBare);
1406        let mut controller = Controller { config, state };
1407        let failure = controller
1408            .adopt_orphan_worker(
1409                session_id,
1410                "local-bare",
1411                None,
1412                None,
1413                false,
1414                &ProcessExecutor,
1415            )
1416            .await
1417            .expect_err("a worker root without a worker cannot complete the handshake");
1418        assert!(
1419            format!("{failure:#}").contains("orphan relay"),
1420            "unexpected failure: {failure:#}"
1421        );
1422        let stored = crate::database::load_state().unwrap();
1423        assert_eq!(stored.sessions[session_id].workspace_id, recovered);
1424        assert!(
1425            stored.sessions[session_id]
1426                .last_error
1427                .as_deref()
1428                .is_some_and(|error| error.contains("orphan adoption failed"))
1429        );
1430    }
1431
1432    #[test]
1433    fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
1434        let mut record = adopted_session_record(
1435            "0123456789abcdef0123456789abcdef",
1436            "local-bare",
1437            "codex".into(),
1438            HarnessKind::Codex,
1439            "project".into(),
1440            mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1441            TargetLocator::LocalBare {
1442                worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
1443            },
1444        );
1445        record.native_session_id = Some("native-session".into());
1446        assert!(!adoption_unfinished(&record, "local-bare"));
1447
1448        record.native_session_id = None;
1449        assert!(
1450            !adoption_unfinished(&record, "other-target"),
1451            "a record adopted onto another target is not this target's retry"
1452        );
1453    }
1454
1455    #[test]
1456    fn recovery_container_scan_requires_both_managed_and_session_labels() {
1457        let template = TargetTemplate::LocalPodman {
1458            container: ConfigContainer {
1459                build_cache: None,
1460                image: "ignored".into(),
1461                pull_policy: Default::default(),
1462                platform: None,
1463                cpus: None,
1464                memory: None,
1465                environment: BTreeMap::new(),
1466                workspace_storage: Default::default(),
1467            },
1468        };
1469        let json = serde_json::json!([
1470            {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef", "dev.mj.instance": "qa0916"}},
1471            {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
1472            {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
1473        ]);
1474        let candidates = candidates_from_container_json(
1475            "local",
1476            &template,
1477            serde_json::to_string(&json).unwrap().as_bytes(),
1478        )
1479        .unwrap();
1480        assert_eq!(candidates.len(), 2);
1481        assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
1482        assert_eq!(candidates[0].instance_id.as_deref(), Some("qa0916"));
1483        assert_eq!(
1484            candidates[1].instance_id, None,
1485            "a container without an instance label is of unknown origin"
1486        );
1487    }
1488
1489    /// Answers the container listing the scan runs and fails everything else,
1490    /// which is what a container with no worker in it does to the ownership
1491    /// probe.
1492    struct ListingExecutor {
1493        listing: String,
1494    }
1495
1496    impl CommandExecutor for ListingExecutor {
1497        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1498            if command.program == "podman" && command.args.first().map(String::as_str) == Some("ps")
1499            {
1500                return Ok(CommandOutput {
1501                    status: 0,
1502                    stdout: self.listing.clone().into_bytes(),
1503                    stderr: Vec::new(),
1504                });
1505            }
1506            Ok(CommandOutput {
1507                status: 1,
1508                stdout: Vec::new(),
1509                stderr: Vec::new(),
1510            })
1511        }
1512    }
1513
1514    fn podman_scan_controller(record: SessionRecord) -> (Controller, ListingExecutor) {
1515        let mut config = Config::default();
1516        config.targets.insert(
1517            "local".into(),
1518            TargetTemplate::LocalPodman {
1519                container: ConfigContainer {
1520                    build_cache: None,
1521                    image: "ignored".into(),
1522                    pull_policy: Default::default(),
1523                    platform: None,
1524                    cpus: None,
1525                    memory: None,
1526                    environment: BTreeMap::new(),
1527                    workspace_storage: Default::default(),
1528                },
1529            },
1530        );
1531        let listing = serde_json::to_string(&serde_json::json!([
1532            {"Labels": {"dev.mj.managed": "true", "dev.mj.session": record.id}}
1533        ]))
1534        .unwrap();
1535        let mut state = State::default();
1536        state.sessions.insert(record.id.clone(), record);
1537        (Controller { config, state }, ListingExecutor { listing })
1538    }
1539
1540    fn errored_podman_session(session_id: &str) -> SessionRecord {
1541        let mut record = adopted_session_record(
1542            session_id,
1543            "local",
1544            "codex".into(),
1545            HarnessKind::Codex,
1546            "project".into(),
1547            mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1548            TargetLocator::LocalPodman {
1549                container_id: targets::resource_name(session_id).unwrap(),
1550                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
1551                borrowed_from: None,
1552            },
1553        );
1554        // What a failed provision or a failed move writes: the session is
1555        // remembered, the locator that could tear its container down is not.
1556        record.state = SessionState::Error;
1557        record.target = None;
1558        record
1559    }
1560
1561    #[test]
1562    fn a_container_an_errored_session_no_longer_names_is_an_orphan() {
1563        let session_id = "0123456789abcdef0123456789abcdef";
1564        let (controller, executor) = podman_scan_controller(errored_podman_session(session_id));
1565
1566        let scan = controller.scan_orphan_workers(&executor, true);
1567
1568        let [candidate] = scan.candidates.as_slice() else {
1569            panic!("the errored session's container was not offered: {scan:?}");
1570        };
1571        assert_eq!(candidate.session_id, session_id);
1572        assert_eq!(
1573            candidate.tracked_session,
1574            Some(SessionState::Error),
1575            "recovery must say the session id is still taken, so only destroy applies"
1576        );
1577    }
1578
1579    #[test]
1580    fn a_container_a_session_still_names_is_not_an_orphan() {
1581        let session_id = "0123456789abcdef0123456789abcdef";
1582        let mut record = errored_podman_session(session_id);
1583        // The same failure, except the record kept its locator: the session's
1584        // own forced destroy still reaches the container, so recovery stays out.
1585        record.target = Some(TargetLocator::LocalPodman {
1586            container_id: targets::resource_name(session_id).unwrap(),
1587            workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
1588            borrowed_from: None,
1589        });
1590        let (controller, executor) = podman_scan_controller(record);
1591
1592        let scan = controller.scan_orphan_workers(&executor, true);
1593
1594        assert!(
1595            scan.candidates.is_empty(),
1596            "a container the controller can still drive was offered for destruction: {scan:?}"
1597        );
1598    }
1599
1600    #[test]
1601    fn a_container_being_provisioned_is_not_an_orphan() {
1602        let session_id = "0123456789abcdef0123456789abcdef";
1603        let mut record = errored_podman_session(session_id);
1604        // Provisioning writes the locator as it runs, so a scan that catches
1605        // the window between `podman run` and locator discovery must not
1606        // mistake the new container for a leftover.
1607        record.state = SessionState::Provisioning;
1608        let (controller, executor) = podman_scan_controller(record);
1609
1610        let scan = controller.scan_orphan_workers(&executor, true);
1611
1612        assert!(
1613            scan.candidates.is_empty(),
1614            "a container still being provisioned was offered for destruction: {scan:?}"
1615        );
1616    }
1617
1618    fn candidate(session_id: &str, instance_id: Option<&str>) -> RecoveryCandidate {
1619        RecoveryCandidate {
1620            session_id: session_id.to_owned(),
1621            target_template_id: "local".to_owned(),
1622            locator: TargetLocator::LocalDocker {
1623                container_id: format!("mj-{session_id}"),
1624                borrowed_from: None,
1625            },
1626            ownership: None,
1627            instance_id: instance_id.map(str::to_owned),
1628            tracked_session: None,
1629        }
1630    }
1631
1632    #[test]
1633    fn default_scan_scope_hides_other_and_unknown_instances() {
1634        let mut scan = RecoveryScan {
1635            candidates: vec![
1636                candidate("mine", Some("qa")),
1637                candidate("theirs", Some("prod")),
1638                candidate("legacy", None),
1639            ],
1640            instance_id: "qa".to_owned(),
1641            ..RecoveryScan::default()
1642        };
1643        restrict_to_instance(&mut scan);
1644        assert_eq!(
1645            scan.candidates
1646                .iter()
1647                .map(|candidate| candidate.session_id.as_str())
1648                .collect::<Vec<_>>(),
1649            ["mine"]
1650        );
1651        assert_eq!(scan.hidden_other_instances, 2);
1652    }
1653
1654    #[test]
1655    fn acting_on_another_or_unknown_instance_requires_the_explicit_flag() {
1656        require_instance_access(&candidate("mine", Some("qa")), "qa", false).unwrap();
1657
1658        let other = require_instance_access(&candidate("theirs", Some("prod")), "qa", false)
1659            .expect_err("another instance's worker is refused by default");
1660        assert!(
1661            other.to_string().contains("belongs to instance \"prod\""),
1662            "{other}"
1663        );
1664        require_instance_access(&candidate("theirs", Some("prod")), "qa", true).unwrap();
1665
1666        let unknown = require_instance_access(&candidate("legacy", None), "qa", false)
1667            .expect_err("a worker without a stamp is refused by default");
1668        assert!(
1669            unknown.to_string().contains("no instance stamp"),
1670            "{unknown}"
1671        );
1672        require_instance_access(&candidate("legacy", None), "qa", true).unwrap();
1673    }
1674
1675    #[test]
1676    fn a_podman_orphan_destroy_plan_removes_its_workspace_volume() {
1677        // `destroy_orphan_worker` rescans, so this drives the two steps it runs
1678        // on the candidate it finds: the scan's locator and the close plan
1679        // built from it. Those are what carried the container-layer default.
1680        let session = "0123456789abcdef0123456789abcdef";
1681        let template = TargetTemplate::LocalPodman {
1682            container: ConfigContainer {
1683                image: "ignored".into(),
1684                pull_policy: Default::default(),
1685                platform: None,
1686                cpus: None,
1687                memory: None,
1688                environment: BTreeMap::new(),
1689                workspace_storage: PodmanWorkspaceStorage::PodmanVolume,
1690                build_cache: None,
1691            },
1692        };
1693        let json = serde_json::json!([
1694            {"Labels": {"dev.mj.managed": "true", "dev.mj.session": session}}
1695        ]);
1696
1697        let candidates = candidates_from_container_json(
1698            "local",
1699            &template,
1700            serde_json::to_string(&json).unwrap().as_bytes(),
1701        )
1702        .unwrap();
1703
1704        let [candidate] = candidates.as_slice() else {
1705            panic!("expected one orphan candidate, got {candidates:?}");
1706        };
1707        let volume = format!("{}-workspace", targets::resource_name(session).unwrap());
1708        assert!(
1709            matches!(
1710                &candidate.locator,
1711                TargetLocator::LocalPodman {
1712                    workspace_storage: PodmanWorkspaceLocator::Volume { name },
1713                    ..
1714                } if name == &volume
1715            ),
1716            "candidate locator lost the volume storage: {:?}",
1717            candidate.locator
1718        );
1719        let backend = recovery_backend_locator(&template, &candidate.locator, session).unwrap();
1720        let plan = targets::close_plan(&backend, session).unwrap();
1721        assert!(
1722            plan.commands.iter().any(|command| {
1723                command.args.iter().any(|argument| argument == &volume)
1724                    && command
1725                        .args
1726                        .iter()
1727                        .any(|argument| argument.contains("podman volume rm"))
1728            }),
1729            "destroy plan does not remove the workspace volume: {plan:?}"
1730        );
1731    }
1732
1733    #[test]
1734    fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1735        let template = TargetTemplate::LocalDocker {
1736            container: ConfigContainer {
1737                build_cache: None,
1738                image: "ignored".into(),
1739                pull_policy: Default::default(),
1740                platform: None,
1741                cpus: None,
1742                memory: None,
1743                environment: BTreeMap::new(),
1744                workspace_storage: Default::default(),
1745            },
1746        };
1747        let session = "0123456789abcdef0123456789abcdef";
1748        let output = format!(
1749            "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session},dev.mj.instance=abc123\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1750        );
1751
1752        let candidates =
1753            candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1754
1755        assert_eq!(candidates.len(), 1);
1756        assert_eq!(candidates[0].session_id, session);
1757        assert_eq!(candidates[0].instance_id.as_deref(), Some("abc123"));
1758        assert!(matches!(
1759            &candidates[0].locator,
1760            TargetLocator::LocalDocker { container_id, .. }
1761                if container_id == &targets::resource_name(session).unwrap()
1762        ));
1763    }
1764
1765    #[test]
1766    fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1767        let json = serde_json::json!({"Reservations": [{"Instances": [{
1768            "InstanceId": "i-exact",
1769            "PrivateIpAddress": "10.0.0.7",
1770            "Tags": [
1771                {"Key": "dev.mj.managed", "Value": "true"},
1772                {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"},
1773                {"Key": "dev.mj.instance", "Value": "qa0916"}
1774            ]
1775        }]}]});
1776        let candidates = candidates_from_aws_json(
1777            "aws",
1778            AwsAddressSource::PrivateIp,
1779            serde_json::to_string(&json).unwrap().as_bytes(),
1780        )
1781        .unwrap();
1782        assert_eq!(candidates[0].instance_id.as_deref(), Some("qa0916"));
1783        assert!(matches!(
1784            &candidates[0].locator,
1785            TargetLocator::AwsEc2 { instance_id, address }
1786                if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1787        ));
1788    }
1789}