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