Skip to main content

start_command/
execution_resume.rs

1//! Resume tracked detached executions (issue #162).
2//!
3//! `--resume <id>` restarts a stored execution, and `--resume <id> -- <command>`
4//! runs a *different* command against the same container filesystem. Both keep
5//! the original execution UUID so `--status`, `--list` and `--upload-log` keep
6//! addressing one logical session across restarts.
7//!
8//! Three strategies, chosen from the probed session state:
9//! - `DockerStart`: the container still exists and the stored command is re-run
10//!   by `docker start` (its original entrypoint).
11//! - `DockerSnapshot`: the container still exists but a new command was given,
12//!   so its filesystem is committed to an image and a derived container runs
13//!   the new command. This avoids `docker start -ai`, which would re-run the
14//!   original entrypoint from scratch.
15//! - `Relaunch`: nothing is left of the session, so the command is launched
16//!   again through the stored isolation options.
17
18use std::path::PathBuf;
19
20use chrono::Utc;
21use serde_json::{json, Value};
22
23use crate::docker_cleanup::{
24    build_docker_runtime_args, docker_command, docker_networks,
25    get_docker_container_cleanup_policy, start_detached_docker_completion_watcher_with,
26    DockerWatcherOptions,
27};
28use crate::docker_resource_limits::{
29    build_resource_limits_status_line, normalize_resource_limits, read_docker_resource_limits,
30};
31use crate::execution_control::{CommandRunner, SystemCommandRunner};
32use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
33use crate::isolation::isolation_log::append_log_file;
34use crate::isolation::{run_isolated, IsolationOptions, IsolationResult};
35use crate::output_blocks::escape_for_links_notation;
36use crate::session_probe::{probe_session, SessionProbe, SessionState};
37
38/// Strategies `build_resume_plan` can pick.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ResumeMode {
41    DockerStart,
42    DockerSnapshot,
43    Relaunch,
44}
45
46impl ResumeMode {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            ResumeMode::DockerStart => "docker-start",
50            ResumeMode::DockerSnapshot => "docker-snapshot",
51            ResumeMode::Relaunch => "relaunch",
52        }
53    }
54}
55
56/// One command run while carrying out a resume plan.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ResumeStep {
59    pub command: String,
60    pub args: Vec<String>,
61    pub description: String,
62}
63
64/// How a stored execution will be resumed.
65#[derive(Debug, Clone)]
66pub struct ResumePlan {
67    pub mode: ResumeMode,
68    pub backend: String,
69    pub session_name: String,
70    /// Set only when a snapshot-derived container replaces the original one.
71    pub new_session_name: Option<String>,
72    pub snapshot_image: Option<String>,
73    pub command: String,
74    pub attempt: u64,
75    /// Resource limits the new container is started with (snapshot resumes
76    /// only, issue #176): `docker commit` does not carry the HostConfig over.
77    pub resource_limits: Vec<String>,
78    pub steps: Vec<ResumeStep>,
79    pub launch_options: Option<IsolationOptions>,
80    pub message: String,
81}
82
83/// Result of `resume_execution`.
84pub struct ExecutionResumeResult {
85    pub success: bool,
86    pub output: Option<String>,
87    pub error: Option<String>,
88}
89
90/// Side effects a resume performs beyond running plan steps.
91///
92/// Injectable so tests can exercise the full resume flow without spawning a
93/// completion watcher, launching an isolation backend, or reading log files.
94pub trait ResumeHooks {
95    /// Attach a fresh completion watcher to a resumed docker session.
96    fn start_watcher(&self, session_name: &str, record: &ExecutionRecord);
97
98    /// Launch the command again through its isolation backend.
99    fn relaunch(&self, backend: &str, command: &str, options: &IsolationOptions)
100        -> IsolationResult;
101
102    /// Resolve the terminal result of a session that ended unsupervised.
103    fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord;
104}
105
106/// The real side effects: docker watcher, isolation backends, log evidence.
107#[derive(Debug, Default)]
108pub struct SystemResumeHooks;
109
110impl ResumeHooks for SystemResumeHooks {
111    fn start_watcher(&self, session_name: &str, record: &ExecutionRecord) {
112        let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
113        start_detached_docker_completion_watcher_with(
114            session_name,
115            get_docker_container_cleanup_policy(&build_launch_options(record)),
116            log_path.as_ref(),
117            // The resumed session gets the same record, so the watcher that
118            // outlives this process finalizes it too (issue #170.1).
119            Some(record.uuid.as_str()),
120            &DockerWatcherOptions {
121                since: None,
122                recover_on_kill: keeps_kill_recovery(session_name, record),
123            },
124        );
125    }
126
127    fn relaunch(
128        &self,
129        backend: &str,
130        command: &str,
131        options: &IsolationOptions,
132    ) -> IsolationResult {
133        run_isolated(backend, command, options)
134    }
135
136    fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord {
137        crate::status_formatter::enrich_detached_status(record)
138    }
139}
140
141/// `docker start` re-runs the launch-time selector, so a container resumed in
142/// place keeps its kill recovery (issue #176); a snapshot-derived container
143/// (a different name) has no selector.
144pub fn keeps_kill_recovery(session_name: &str, record: &ExecutionRecord) -> bool {
145    record_option(record, "sessionName") == Some(session_name)
146        && record
147            .options
148            .get("onKillResume")
149            .is_some_and(|value| value.as_u64().unwrap_or(0) > 0)
150}
151
152fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
153    record.options.get(key).and_then(|value| value.as_str())
154}
155
156fn record_flag(record: &ExecutionRecord, key: &str) -> bool {
157    record
158        .options
159        .get(key)
160        .and_then(|value| value.as_bool())
161        .unwrap_or(false)
162}
163
164fn record_strings(record: &ExecutionRecord, key: &str) -> Vec<String> {
165    record
166        .options
167        .get(key)
168        .and_then(|value| value.as_array())
169        .map(|items| {
170            items
171                .iter()
172                .filter_map(|item| item.as_str().map(str::to_string))
173                .collect()
174        })
175        .unwrap_or_default()
176}
177
178/// Build the docker image name used to snapshot a container before running a
179/// new command in it. Docker repository names must be lowercase and limited to
180/// `[a-z0-9._-]`, so session names are sanitized.
181pub fn build_snapshot_image_name(session_name: &str, attempt: u64) -> String {
182    let mut sanitized = String::new();
183    let mut pending_dash = false;
184    for ch in session_name.to_lowercase().chars() {
185        if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-' {
186            if pending_dash {
187                sanitized.push('-');
188                pending_dash = false;
189            }
190            sanitized.push(ch);
191        } else if !sanitized.is_empty() || !pending_dash {
192            pending_dash = true;
193        }
194    }
195    if pending_dash {
196        sanitized.push('-');
197    }
198    let sanitized = sanitized.trim_start_matches(['-', '.']).to_string();
199    let sanitized = if sanitized.is_empty() {
200        "session".to_string()
201    } else {
202        sanitized
203    };
204    format!("start-command-resume/{}:{}", sanitized, attempt)
205}
206
207/// Build the container name for a snapshot-based resume.
208pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
209    format!("{}-resume-{}", session_name, attempt)
210}
211
212/// Rebuild the isolation options stored on a record so the command can be
213/// launched again with the same configuration.
214pub fn build_launch_options(record: &ExecutionRecord) -> IsolationOptions {
215    let networks = record_strings(record, "networks");
216    IsolationOptions {
217        session: record_option(record, "sessionName").map(str::to_string),
218        image: record_option(record, "image").map(str::to_string),
219        volumes: record_strings(record, "volumes"),
220        mounts: record_strings(record, "mounts"),
221        env: record_strings(record, "env"),
222        privileged: record_flag(record, "privileged"),
223        network: networks.first().cloned(),
224        networks,
225        network_aliases: record_strings(record, "networkAliases"),
226        // Limits captured from the container on an earlier resume (issue #176).
227        resource_limits: normalize_resource_limits(record.options.get("resourceLimits")),
228        on_kill_resume: None,
229        recovery_command: None,
230        endpoint: record_option(record, "endpoint").map(str::to_string),
231        detached: true,
232        user: record_option(record, "user").map(str::to_string),
233        keep_alive: record_flag(record, "keepAlive"),
234        auto_remove_docker_container: record_flag(record, "autoRemoveDockerContainer"),
235        always_cleanup_container: record_flag(record, "alwaysCleanupContainer"),
236        keep_container: record_flag(record, "keepContainer"),
237        keep_container_on_fail: record_flag(record, "keepContainerOnFail"),
238        shell: record_option(record, "shell").unwrap_or("auto").to_string(),
239        // Append to the same log so one logical session keeps one gap-free record.
240        log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
241        // The relaunched session keeps writing to this record, so its watcher
242        // is the one that must mark it terminal (issue #170.1).
243        execution_id: Some(record.uuid.clone()),
244    }
245}
246
247/// Build the steps that start a snapshot-derived container.
248///
249/// `docker run` can only join one network, so a session that was launched on
250/// several networks is rebuilt the same way `run_isolated` builds it: create
251/// the container, connect the additional networks, then start it. Dropping to a
252/// single `docker run` when there is nothing extra to connect keeps the common
253/// case to one command.
254fn build_snapshot_start_steps(
255    docker: &str,
256    container_args: Vec<String>,
257    extra_networks: &[String],
258    new_session_name: &str,
259    snapshot_image: &str,
260) -> Vec<ResumeStep> {
261    if extra_networks.is_empty() {
262        let mut args = vec!["run".to_string(), "-d".to_string()];
263        args.extend(container_args);
264        return vec![ResumeStep {
265            command: docker.to_string(),
266            args,
267            description: format!("Run the new command in {}", new_session_name),
268        }];
269    }
270
271    let mut create_args = vec!["create".to_string()];
272    create_args.extend(container_args);
273    let mut steps = vec![ResumeStep {
274        command: docker.to_string(),
275        args: create_args,
276        description: format!("Create {} from {}", new_session_name, snapshot_image),
277    }];
278    for network in extra_networks {
279        steps.push(ResumeStep {
280            command: docker.to_string(),
281            args: vec![
282                "network".to_string(),
283                "connect".to_string(),
284                network.clone(),
285                new_session_name.to_string(),
286            ],
287            description: format!("Connect {} to network {}", new_session_name, network),
288        });
289    }
290    steps.push(ResumeStep {
291        command: docker.to_string(),
292        args: vec!["start".to_string(), new_session_name.to_string()],
293        description: format!("Run the new command in {}", new_session_name),
294    });
295    steps
296}
297
298fn docker_snapshot_plan(
299    record: &ExecutionRecord,
300    backend: &str,
301    session_name: &str,
302    command: &str,
303    attempt: u64,
304    live_resource_limits: Option<Vec<String>>,
305) -> ResumePlan {
306    let snapshot_image = build_snapshot_image_name(session_name, attempt);
307    let new_session_name = build_resumed_session_name(session_name, attempt);
308    let docker = docker_command().to_string_lossy().to_string();
309
310    let mut launch_options = build_launch_options(record);
311    // `docker commit` keeps the filesystem but not the HostConfig, so the
312    // limits must be passed to the new container explicitly (issue #176).
313    if let Some(limits) = live_resource_limits {
314        launch_options.resource_limits = limits;
315    }
316    let mut container_args = vec!["--name".to_string(), new_session_name.clone()];
317    if let Some(user) = record_option(record, "user") {
318        container_args.push("--user".to_string());
319        container_args.push(user.to_string());
320    }
321    container_args.extend(
322        build_docker_runtime_args(&launch_options)
323            .into_iter()
324            .map(str::to_string),
325    );
326    container_args.push(snapshot_image.clone());
327    container_args.push("sh".to_string());
328    container_args.push("-c".to_string());
329    container_args.push(command.to_string());
330
331    let mut steps = vec![ResumeStep {
332        command: docker.clone(),
333        args: vec![
334            "commit".to_string(),
335            session_name.to_string(),
336            snapshot_image.clone(),
337        ],
338        description: format!("Snapshot container {} as {}", session_name, snapshot_image),
339    }];
340    steps.extend(build_snapshot_start_steps(
341        &docker,
342        container_args,
343        &docker_networks(&launch_options)
344            .into_iter()
345            .skip(1)
346            .map(str::to_string)
347            .collect::<Vec<_>>(),
348        &new_session_name,
349        &snapshot_image,
350    ));
351
352    ResumePlan {
353        mode: ResumeMode::DockerSnapshot,
354        backend: backend.to_string(),
355        session_name: session_name.to_string(),
356        new_session_name: Some(new_session_name.clone()),
357        snapshot_image: Some(snapshot_image.clone()),
358        command: command.to_string(),
359        attempt,
360        resource_limits: launch_options.resource_limits.clone(),
361        steps,
362        launch_options: None,
363        message: format!(
364            "Resumed session in new container {} from snapshot of {}",
365            new_session_name, session_name
366        ),
367    }
368}
369
370/// Decide how a stored execution should be resumed.
371pub fn build_resume_plan(
372    record: &ExecutionRecord,
373    new_command: Option<&str>,
374    probe: &SessionProbe,
375) -> Result<ResumePlan, String> {
376    build_resume_plan_with_limits(record, new_command, probe, None)
377}
378
379/// [`build_resume_plan`] with the resource limits read from the stopped
380/// container with `docker inspect`; falls back to the stored ones when `None`.
381pub fn build_resume_plan_with_limits(
382    record: &ExecutionRecord,
383    new_command: Option<&str>,
384    probe: &SessionProbe,
385    live_resource_limits: Option<Vec<String>>,
386) -> Result<ResumePlan, String> {
387    let session_name = record_option(record, "sessionName")
388        .ok_or_else(|| "Execution record does not contain an isolation session name.".to_string())?
389        .to_string();
390
391    if record_option(record, "isolationMode") != Some("detached") {
392        return Err("Only detached isolated executions can be resumed.".to_string());
393    }
394
395    if probe.alive {
396        return Err(format!(
397            "Session \"{}\" is still running. Use `$ --attach {}` to re-enter it, or `$ --stop {}` first.",
398            session_name, record.uuid, record.uuid
399        ));
400    }
401
402    let backend = record_option(record, "isolated")
403        .unwrap_or("unknown")
404        .to_string();
405    let command = new_command.unwrap_or(record.command.as_str()).to_string();
406    if command.is_empty() {
407        return Err(format!(
408            "Execution \"{}\" has no stored command to resume.",
409            record.uuid
410        ));
411    }
412
413    let attempt = record
414        .options
415        .get("resumeCount")
416        .and_then(|value| value.as_u64())
417        .unwrap_or(0)
418        + 1;
419
420    if backend == "docker" && probe.state == SessionState::Stopped {
421        return Ok(match new_command {
422            None => ResumePlan {
423                mode: ResumeMode::DockerStart,
424                backend,
425                new_session_name: None,
426                snapshot_image: None,
427                command,
428                attempt,
429                resource_limits: Vec::new(),
430                steps: vec![ResumeStep {
431                    command: docker_command().to_string_lossy().to_string(),
432                    args: vec!["start".to_string(), session_name.clone()],
433                    description: format!("Start stopped container {}", session_name),
434                }],
435                launch_options: None,
436                message: format!("Resumed detached docker container: {}", session_name),
437                session_name,
438            },
439            Some(new_command) => docker_snapshot_plan(
440                record,
441                &backend,
442                &session_name,
443                new_command,
444                attempt,
445                live_resource_limits,
446            ),
447        });
448    }
449
450    Ok(ResumePlan {
451        mode: ResumeMode::Relaunch,
452        message: format!("Relaunched {} session: {}", backend, session_name),
453        backend,
454        new_session_name: None,
455        snapshot_image: None,
456        command,
457        attempt,
458        resource_limits: Vec::new(),
459        steps: Vec::new(),
460        launch_options: Some(build_launch_options(record)),
461        session_name,
462    })
463}
464
465/// Fields reported after a resume attempt.
466pub struct ResumeResultFields<'a> {
467    pub identifier: &'a str,
468    pub uuid: &'a str,
469    pub mode: ResumeMode,
470    pub backend: &'a str,
471    pub session_name: &'a str,
472    pub previous_session_name: Option<&'a str>,
473    pub snapshot_image: Option<&'a str>,
474    /// Limits the snapshot-derived container was started with (issue #176).
475    pub resource_limits: &'a [String],
476    pub command: &'a str,
477    pub message: &'a str,
478}
479
480/// Format a resume result as links notation.
481pub fn format_resume_result_as_links_notation(result: &ResumeResultFields) -> String {
482    let mut lines = vec![
483        "executionResume".to_string(),
484        format!(
485            "  identifier {}",
486            escape_for_links_notation(result.identifier)
487        ),
488        format!("  uuid {}", escape_for_links_notation(result.uuid)),
489        format!("  mode {}", escape_for_links_notation(result.mode.as_str())),
490        format!("  backend {}", escape_for_links_notation(result.backend)),
491        format!(
492            "  sessionName {}",
493            escape_for_links_notation(result.session_name)
494        ),
495    ];
496    if let Some(previous) = result.previous_session_name {
497        lines.push(format!(
498            "  previousSessionName {}",
499            escape_for_links_notation(previous)
500        ));
501    }
502    if let Some(image) = result.snapshot_image {
503        lines.push(format!(
504            "  snapshotImage {}",
505            escape_for_links_notation(image)
506        ));
507    }
508    if !result.resource_limits.is_empty() {
509        lines.push(format!(
510            "  resourceLimits {}",
511            escape_for_links_notation(&result.resource_limits.join(" "))
512        ));
513    }
514    lines.push(format!(
515        "  command {}",
516        escape_for_links_notation(result.command)
517    ));
518    lines.push(format!(
519        "  message {}",
520        escape_for_links_notation(result.message)
521    ));
522    lines.join("\n")
523}
524
525/// Format a resume result in the requested output format.
526pub fn format_resume_result(result: &ResumeResultFields, output_format: Option<&str>) -> String {
527    match output_format {
528        Some("json") => {
529            let mut value = json!({
530                "identifier": result.identifier,
531                "uuid": result.uuid,
532                "mode": result.mode.as_str(),
533                "backend": result.backend,
534                "sessionName": result.session_name,
535                "previousSessionName": result.previous_session_name,
536                "snapshotImage": result.snapshot_image,
537                "resourceLimits": (!result.resource_limits.is_empty())
538                    .then_some(result.resource_limits),
539                "command": result.command,
540                "message": result.message,
541            });
542            if let Value::Object(ref mut map) = value {
543                map.retain(|_, entry| !entry.is_null());
544            }
545            serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
546        }
547        Some("text") => {
548            let mut lines = vec![
549                format!("Resume Mode:   {}", result.mode.as_str()),
550                format!("UUID:          {}", result.uuid),
551                format!("Backend:       {}", result.backend),
552                format!("Session Name:  {}", result.session_name),
553                format!("Command:       {}", result.command),
554            ];
555            if !result.resource_limits.is_empty() {
556                lines.push(format!(
557                    "Resource Limits: {}",
558                    result.resource_limits.join(" ")
559                ));
560            }
561            lines.push(result.message.to_string());
562            lines.join("\n")
563        }
564        _ => format_resume_result_as_links_notation(result),
565    }
566}
567
568/// Apply the resume outcome to the stored record, keeping the original UUID so
569/// one logical session stays addressable across restarts.
570pub fn apply_resume_to_record(
571    record: &mut ExecutionRecord,
572    plan: &ResumePlan,
573    container_id: Option<&str>,
574) {
575    record
576        .options
577        .insert("resumeCount".to_string(), json!(plan.attempt));
578    record
579        .options
580        .insert("resumedAt".to_string(), json!(Utc::now().to_rfc3339()));
581    // A resume is a new deliberate start: launch-time recovery applies again.
582    record.options.remove("stopRequestedAt");
583    if !plan.resource_limits.is_empty() {
584        record
585            .options
586            .insert("resourceLimits".to_string(), json!(plan.resource_limits));
587    }
588
589    if let Some(new_session_name) = &plan.new_session_name {
590        let mut history = record
591            .options
592            .get("sessionNameHistory")
593            .and_then(|value| value.as_array())
594            .cloned()
595            .unwrap_or_default();
596        if let Some(previous) = record.options.get("sessionName").cloned() {
597            history.push(previous);
598        }
599        record
600            .options
601            .insert("sessionNameHistory".to_string(), Value::Array(history));
602        record
603            .options
604            .insert("sessionName".to_string(), json!(new_session_name));
605    }
606    if let Some(snapshot_image) = &plan.snapshot_image {
607        record
608            .options
609            .insert("image".to_string(), json!(snapshot_image));
610    }
611    if let Some(container_id) = container_id {
612        record
613            .options
614            .insert("containerId".to_string(), json!(container_id));
615    }
616
617    record.command = plan.command.clone();
618    record.status = ExecutionStatus::Executing;
619    record.exit_code = None;
620    record.end_time = None;
621    record.exit_reason = None;
622    record.oom_killed = None;
623}
624
625fn active_session_name(plan: &ResumePlan) -> &str {
626    plan.new_session_name
627        .as_deref()
628        .unwrap_or(&plan.session_name)
629}
630
631/// Resume a tracked execution by UUID or session name.
632pub fn resume_execution(
633    store: Option<&ExecutionStore>,
634    identifier: &str,
635    command: Option<&str>,
636    output_format: Option<&str>,
637) -> ExecutionResumeResult {
638    resume_execution_with(
639        store,
640        identifier,
641        command,
642        output_format,
643        &SystemCommandRunner,
644        &SystemResumeHooks,
645    )
646}
647
648/// Resume with an injectable command runner and hooks, so tests never touch
649/// docker.
650pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
651    store: Option<&ExecutionStore>,
652    identifier: &str,
653    command: Option<&str>,
654    output_format: Option<&str>,
655    runner: &R,
656    hooks: &H,
657) -> ExecutionResumeResult {
658    let Some(store) = store else {
659        return ExecutionResumeResult {
660            success: false,
661            output: None,
662            error: Some("Execution tracking is disabled.".to_string()),
663        };
664    };
665
666    let Some(mut record) = store.get(identifier) else {
667        return ExecutionResumeResult {
668            success: false,
669            output: None,
670            error: Some(format!(
671                "No execution found with UUID or session name: {}",
672                identifier
673            )),
674        };
675    };
676
677    let probe = probe_session(&record, runner);
678    // Only a snapshot resume creates a new container, so only it needs the old
679    // container's limits (issue #176).
680    let live_resource_limits = match (command, record_option(&record, "sessionName")) {
681        (Some(_), Some(session_name))
682            if record_option(&record, "isolated") == Some("docker")
683                && !probe.alive
684                && probe.state == SessionState::Stopped =>
685        {
686            read_docker_resource_limits(session_name, runner)
687        }
688        _ => None,
689    };
690    let plan = match build_resume_plan_with_limits(&record, command, &probe, live_resource_limits) {
691        Ok(plan) => plan,
692        Err(error) => {
693            return ExecutionResumeResult {
694                success: false,
695                output: None,
696                error: Some(error),
697            }
698        }
699    };
700
701    let mut container_id: Option<String> = None;
702
703    if plan.mode == ResumeMode::Relaunch {
704        let launch_options = plan.launch_options.clone().unwrap_or_default();
705        let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
706        if !launch_result.success {
707            return ExecutionResumeResult {
708                success: false,
709                output: None,
710                error: Some(format!(
711                    "Failed to relaunch {} session \"{}\": {}",
712                    plan.backend, plan.session_name, launch_result.message
713                )),
714            };
715        }
716        container_id = launch_result.container_id;
717    } else {
718        for step in &plan.steps {
719            let result = runner.run(&step.command, &step.args);
720            if !result.success {
721                let detail = if !result.stderr.trim().is_empty() {
722                    result.stderr.trim().to_string()
723                } else {
724                    result.error.clone().unwrap_or_else(|| {
725                        format!(
726                            "exit code {}",
727                            result
728                                .status
729                                .map(|code| code.to_string())
730                                .unwrap_or_else(|| "unknown".to_string())
731                        )
732                    })
733                };
734                return ExecutionResumeResult {
735                    success: false,
736                    output: None,
737                    error: Some(format!(
738                        "Failed to resume {} session \"{}\": {}",
739                        plan.backend, plan.session_name, detail
740                    )),
741                };
742            }
743            let stdout = result.stdout.trim().to_string();
744            if !stdout.is_empty() {
745                container_id = Some(stdout);
746            }
747        }
748
749        if let Some(line) = build_resource_limits_status_line(&plan.resource_limits) {
750            if !record.log_path.is_empty() {
751                append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", line));
752            }
753        }
754
755        // The completion watcher died with the previous run (or with the
756        // supervisor), so a new one must follow the resumed container.
757        hooks.start_watcher(active_session_name(&plan), &record);
758    }
759
760    let previous_session_name = plan
761        .new_session_name
762        .as_ref()
763        .map(|_| plan.session_name.clone());
764    apply_resume_to_record(&mut record, &plan, container_id.as_deref());
765    if let Err(error) = store.save(&record) {
766        return ExecutionResumeResult {
767            success: false,
768            output: None,
769            error: Some(error),
770        };
771    }
772
773    let session_name = record_option(&record, "sessionName")
774        .unwrap_or(&plan.session_name)
775        .to_string();
776
777    ExecutionResumeResult {
778        success: true,
779        output: Some(format_resume_result(
780            &ResumeResultFields {
781                identifier,
782                uuid: &record.uuid,
783                mode: plan.mode,
784                backend: &plan.backend,
785                session_name: &session_name,
786                previous_session_name: previous_session_name.as_deref(),
787                snapshot_image: plan.snapshot_image.as_deref(),
788                resource_limits: &plan.resource_limits,
789                command: &plan.command,
790                message: &plan.message,
791            },
792            output_format,
793        )),
794        error: None,
795    }
796}
797
798#[cfg(test)]
799#[path = "execution_resume_cases.rs"]
800mod tests;