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