Skip to main content

start_command/
execution_resume_flow.rs

1//! Apply a durable resume plan without holding the database lock over Docker.
2use super::*;
3
4/// Resume a tracked execution by UUID or session name.
5pub fn resume_execution(
6    store: Option<&ExecutionStore>,
7    identifier: &str,
8    command: Option<&str>,
9    output_format: Option<&str>,
10) -> ExecutionResumeResult {
11    resume_execution_with(
12        store,
13        identifier,
14        command,
15        output_format,
16        &SystemCommandRunner,
17        &SystemResumeHooks,
18    )
19}
20
21/// Resume with an injectable command runner and hooks, so tests never touch
22/// docker.
23pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
24    store: Option<&ExecutionStore>,
25    identifier: &str,
26    command: Option<&str>,
27    output_format: Option<&str>,
28    runner: &R,
29    hooks: &H,
30) -> ExecutionResumeResult {
31    resume_execution_with_resources(
32        store,
33        identifier,
34        command,
35        output_format,
36        runner,
37        hooks,
38        &json!({}),
39    )
40}
41
42pub fn resume_execution_with_options(
43    store: Option<&ExecutionStore>,
44    identifier: &str,
45    command: Option<&str>,
46    output_format: Option<&str>,
47    options: &crate::args_parser::WrapperOptions,
48) -> ExecutionResumeResult {
49    let mut overrides = crate::docker_resource_options::specs(options);
50    overrides["removeOriginal"] = json!(options.remove_original);
51    overrides["labels"] = json!(options.labels);
52    resume_execution_with_resources(
53        store,
54        identifier,
55        command,
56        output_format,
57        &SystemCommandRunner,
58        &SystemResumeHooks,
59        &overrides,
60    )
61}
62
63#[allow(clippy::too_many_arguments)]
64pub fn resume_execution_with_resources<R: CommandRunner, H: ResumeHooks>(
65    store: Option<&ExecutionStore>,
66    identifier: &str,
67    command: Option<&str>,
68    output_format: Option<&str>,
69    runner: &R,
70    hooks: &H,
71    overrides: &Value,
72) -> ExecutionResumeResult {
73    let Some(store) = store else {
74        return ExecutionResumeResult {
75            success: false,
76            output: None,
77            error: Some("Execution tracking is disabled.".to_string()),
78        };
79    };
80
81    let Some(mut record) = store.get(identifier) else {
82        return ExecutionResumeResult {
83            success: false,
84            output: None,
85            error: Some(format!(
86                "No execution found with UUID or session name: {}",
87                identifier
88            )),
89        };
90    };
91
92    if crate::launch_owner::has_active_launch(&record) {
93        return ExecutionResumeResult {
94            success: false,
95            output: None,
96            error: Some("An execution launch is already reserved.".into()),
97        };
98    }
99    let previous = record.clone();
100    let probe = probe_session(&record, runner);
101    let resources = crate::resume_resources::prepare(&record, &probe, overrides, runner);
102    let (limits, resolved, old_memory) = match resources {
103        Ok(result) => result,
104        Err(error) => {
105            return ExecutionResumeResult {
106                success: false,
107                output: None,
108                error: Some(error),
109            }
110        }
111    };
112    let mut planning_record = record.clone();
113    if let Some(labels) = overrides
114        .get("labels")
115        .and_then(Value::as_array)
116        .filter(|labels| !labels.is_empty())
117    {
118        let mut merged = record_strings(&record, "labels");
119        for label in labels.iter().filter_map(Value::as_str) {
120            let key = label.split('=').next().unwrap_or(label);
121            if let Some(index) = merged
122                .iter()
123                .position(|existing| existing.split('=').next() == Some(key))
124            {
125                merged[index] = label.to_string();
126            } else {
127                merged.push(label.to_string());
128            }
129        }
130        planning_record
131            .options
132            .insert("labels".into(), json!(merged));
133    }
134    if planning_record.options.get("labels") != record.options.get("labels") {
135        if record_option(&record, "isolated") != Some("docker") {
136            return ExecutionResumeResult {
137                success: false,
138                output: None,
139                error: Some("Docker labels require a Docker execution when resuming.".into()),
140            };
141        }
142        planning_record
143            .options
144            .insert("forceSnapshot".into(), json!(true));
145    }
146    if let Some(limits) = &limits {
147        planning_record
148            .options
149            .insert("resourceLimits".into(), json!(limits));
150    }
151    let mut plan =
152        match build_resume_plan_with_limits(&planning_record, command, &probe, limits.clone()) {
153            Ok(plan) => plan,
154            Err(error) => {
155                return ExecutionResumeResult {
156                    success: false,
157                    output: None,
158                    error: Some(error),
159                }
160            }
161        };
162    if let Some(limits) = limits {
163        plan.resource_limits = limits.clone();
164        let changed = ["memory", "memorySwap", "cpus"]
165            .iter()
166            .any(|k| overrides.get(k).and_then(Value::as_str).is_some())
167            || record.options.contains_key("cpuPenaltyConfig");
168        if plan.mode == ResumeMode::DockerStart && changed {
169            let mut args = vec!["update".into()];
170            args.extend(limits.into_iter().filter(|s| {
171                [
172                    "--memory=",
173                    "--memory-swap=",
174                    "--cpus=",
175                    "--cpu-quota=",
176                    "--cpu-period=",
177                ]
178                .iter()
179                .any(|f| s.starts_with(f))
180            }));
181            args.push(plan.session_name.clone());
182            plan.steps.insert(
183                0,
184                ResumeStep {
185                    command: docker_command().to_string_lossy().into(),
186                    args,
187                    description: "Apply resume limits before start".into(),
188                },
189            );
190        }
191    }
192    let mut container_id: Option<String> = None;
193    let mut attempt = crate::execution_attempt::create_attempt(
194        &record,
195        plan.mode.as_str(),
196        active_session_name(&plan),
197    );
198    let mut lifecycle_record = record.clone();
199    lifecycle_record.attempt = Some(attempt.clone());
200    crate::execution_attempt::append_lifecycle(&lifecycle_record, "resume-started", json!({}));
201    apply_resume_to_record(&mut record, &plan, None);
202    if let Some(labels) = planning_record.options.get("labels") {
203        record.options.insert("labels".into(), labels.clone());
204    }
205    record.attempt = Some(attempt.clone());
206    if let Some(resolved) = resolved {
207        record.options.insert("resolvedLimits".into(), resolved);
208    }
209    let mut requested = record
210        .options
211        .get("resourceLimitSpecs")
212        .filter(|value| value.is_object())
213        .cloned()
214        .unwrap_or_else(|| json!({}));
215    for k in ["memory", "memorySwap", "cpus"] {
216        if let Some(value) = overrides.get(k).filter(|v| v.is_string()) {
217            requested[k] = value.clone();
218        }
219    }
220    record
221        .options
222        .insert("resourceLimitSpecs".into(), requested);
223    if record.options.contains_key("cpuPenaltyConfig")
224        && overrides.get("cpus").and_then(Value::as_str).is_some()
225    {
226        record
227            .options
228            .insert("baseResourceLimits".into(), json!(&plan.resource_limits));
229    }
230    crate::launch_owner::mark_launch(&mut record);
231    if let Err(error) = store.reserve_launch(&record, &previous) {
232        return ExecutionResumeResult {
233            success: false,
234            output: None,
235            error: Some(format!("Launch reservation failed: {}", error)),
236        };
237    }
238
239    if let Some(spec) = overrides.get("memory").and_then(Value::as_str) {
240        let new = record
241            .options
242            .get("resolvedLimits")
243            .and_then(|v| v.get("memory"))
244            .cloned()
245            .unwrap_or(Value::Null);
246        if !record.log_path.is_empty() {
247            append_log_file(
248                &PathBuf::from(&record.log_path),
249                &format!(
250                    "Resume memory limit: {} -> {} ({})\n",
251                    old_memory, new, spec
252                ),
253            );
254        }
255    }
256
257    if plan.mode == ResumeMode::Relaunch {
258        let launch_options = plan.launch_options.clone().unwrap_or_default();
259        let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
260        if !launch_result.success {
261            let _ = store.save(&previous);
262            crate::execution_attempt::append_lifecycle(
263                &lifecycle_record,
264                "launch-failed",
265                json!({ "error": launch_result.message }),
266            );
267            return ExecutionResumeResult {
268                success: false,
269                output: None,
270                error: Some(format!(
271                    "Failed to relaunch {} session \"{}\": {}",
272                    plan.backend, plan.session_name, launch_result.message
273                )),
274            };
275        }
276        container_id = launch_result.container_id;
277    } else {
278        let mut snapshot_created = false;
279        let mut derived_created = false;
280        let handoff = plan.mode == ResumeMode::DockerStart
281            && command.is_some()
282            && record_flag(&previous, "commandHandoff");
283        let launch_result = (|| -> Result<(), String> {
284            let _snapshot_lock = if plan.mode == ResumeMode::DockerSnapshot {
285                Some(crate::docker_snapshot_safety::acquire_snapshot_lock()?)
286            } else {
287                None
288            };
289            if plan.mode == ResumeMode::DockerSnapshot {
290                let message =
291                    crate::docker_snapshot_safety::preflight_snapshot(&plan.session_name, runner)?;
292                plan.message.push_str(&format!("\n{}", message));
293                if !record.log_path.is_empty() {
294                    append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", message));
295                }
296            }
297            if handoff {
298                let copied = crate::docker_command_handoff::write_command_handoff(
299                    &plan.session_name,
300                    &plan.command,
301                    record_option(&previous, "shell").unwrap_or("auto"),
302                    record_flag(&previous, "keepAlive"),
303                    runner,
304                )?;
305                if !copied.success {
306                    return Err(if copied.stderr.trim().is_empty() {
307                        copied.error.unwrap_or_else(|| {
308                            "Could not copy replacement command into the stopped container".into()
309                        })
310                    } else {
311                        copied.stderr.trim().into()
312                    });
313                }
314            }
315            for step in &plan.steps {
316                let result = runner.run(&step.command, &step.args);
317                if !result.success {
318                    let detail = if !result.stderr.trim().is_empty() {
319                        result.stderr.trim().to_string()
320                    } else {
321                        result.error.clone().unwrap_or_else(|| {
322                            format!(
323                                "exit code {}",
324                                result
325                                    .status
326                                    .map(|code| code.to_string())
327                                    .unwrap_or_else(|| "unknown".to_string())
328                            )
329                        })
330                    };
331                    return Err(detail);
332                }
333                if step.args[0] == "commit" {
334                    snapshot_created = true;
335                }
336                if step.args[0] == "create" {
337                    derived_created = true;
338                }
339                let stdout = result.stdout.trim().to_string();
340                if !stdout.is_empty() && matches!(step.args[0].as_str(), "run" | "create") {
341                    container_id = Some(stdout);
342                }
343            }
344            if plan.mode == ResumeMode::DockerSnapshot
345                && overrides.get("removeOriginal").and_then(Value::as_bool) == Some(true)
346            {
347                let running = runner.run(
348                    &docker_command().to_string_lossy(),
349                    &[
350                        "inspect".into(),
351                        "-f".into(),
352                        "{{.State.Running}}".into(),
353                        active_session_name(&plan).into(),
354                    ],
355                );
356                let note = if running.success && running.stdout.trim() == "true" {
357                    let removed = runner.run(
358                        &docker_command().to_string_lossy(),
359                        &["rm".into(), plan.session_name.clone()],
360                    );
361                    if removed.success {
362                        format!("Stopped original container removed: {}", plan.session_name)
363                    } else {
364                        format!("Original container retained: {}", removed.stderr.trim())
365                    }
366                } else {
367                    "Original container retained: the snapshot container is not running.".into()
368                };
369                plan.message.push_str(&format!("\n{}", note));
370                if !record.log_path.is_empty() {
371                    append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", note));
372                }
373            }
374            Ok(())
375        })();
376        if let Err(detail) = launch_result {
377            if derived_created {
378                runner.run(
379                    &docker_command().to_string_lossy(),
380                    &["rm".into(), active_session_name(&plan).into()],
381                );
382            }
383            if snapshot_created {
384                runner.run(
385                    &docker_command().to_string_lossy(),
386                    &["rmi".into(), plan.snapshot_image.clone().unwrap()],
387                );
388            }
389            if handoff {
390                let _ = crate::docker_command_handoff::write_command_handoff(
391                    &plan.session_name,
392                    &previous.command,
393                    record_option(&previous, "shell").unwrap_or("auto"),
394                    record_flag(&previous, "keepAlive"),
395                    runner,
396                );
397            }
398            let _ = store.save(&previous);
399            crate::execution_attempt::append_lifecycle(
400                &lifecycle_record,
401                "launch-failed",
402                json!({ "error": detail }),
403            );
404            return ExecutionResumeResult {
405                success: false,
406                output: None,
407                error: Some(format!(
408                    "Failed to resume {} session \"{}\": {}",
409                    plan.backend, plan.session_name, detail
410                )),
411            };
412        }
413
414        if let Some(line) = build_resource_limits_status_line(&plan.resource_limits) {
415            if !record.log_path.is_empty() {
416                append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", line));
417            }
418        }
419    }
420
421    let previous_session_name = plan
422        .new_session_name
423        .as_ref()
424        .map(|_| plan.session_name.clone());
425    if let Some(id) = container_id.as_deref() {
426        record.options.insert("containerId".into(), json!(id));
427    }
428    record.options.insert("launchPending".into(), json!(false));
429    attempt.launch_accepted_at = Some(Utc::now().to_rfc3339());
430    record
431        .options
432        .insert("resumedAt".into(), json!(attempt.started_at));
433    record.attempt = Some(attempt.clone());
434    if let Err(error) = store.save(&record) {
435        if plan.backend == "docker" {
436            let _ = hooks.attach_watcher(active_session_name(&plan), &record);
437        }
438        let stopped = plan.backend == "docker"
439            && runner
440                .run(
441                    &docker_command().to_string_lossy(),
442                    &["stop".into(), active_session_name(&plan).into()],
443                )
444                .success;
445        if stopped {
446            record.status = ExecutionStatus::Executed;
447            record.exit_code = Some(-1);
448            record.end_time = Some(Utc::now().to_rfc3339());
449            record.options.insert("launchPending".into(), json!(false));
450            let _ = store.save(&record);
451        }
452        return ExecutionResumeResult {
453            success: false,
454            output: None,
455            error: Some(
456                json!({"code": "LAUNCH_PERSISTENCE_FAILED", "uuid": record.uuid,
457                "containerName": active_session_name(&plan), "running": !stopped,
458                "error": error})
459                .to_string(),
460            ),
461        };
462    }
463    crate::execution_attempt::append_lifecycle(&record, "launch-accepted", json!({}));
464    if plan.backend == "docker" {
465        let attachment = hooks.attach_watcher(active_session_name(&plan), &record);
466        let fields = match &attachment {
467            Ok(()) => json!({"watcherAttachedAt": Utc::now().to_rfc3339()}),
468            Err(error) => json!({"watcherError": error}),
469        };
470        match store.patch_attempt(&record.uuid, attempt.number, fields) {
471            Ok(Some(current)) => crate::execution_attempt::append_lifecycle(
472                &current,
473                if attachment.is_ok() {
474                    "watcher-attached"
475                } else {
476                    "watcher-attachment-failed"
477                },
478                json!({"error": attachment.as_ref().err()}),
479            ),
480            Ok(None) => {}
481            Err(error) => {
482                return ExecutionResumeResult {
483                    success: false,
484                    output: None,
485                    error: Some(error),
486                }
487            }
488        }
489        if let Err(error) = attachment {
490            return ExecutionResumeResult {
491                success: false,
492                output: None,
493                error: Some(format!(
494                    "Launch accepted, but completion watcher attachment failed: {}",
495                    error
496                )),
497            };
498        }
499    }
500
501    let session_name = record_option(&record, "sessionName")
502        .unwrap_or(&plan.session_name)
503        .to_string();
504
505    ExecutionResumeResult {
506        success: true,
507        output: Some(format_resume_result(
508            &ResumeResultFields {
509                identifier,
510                uuid: &record.uuid,
511                mode: plan.mode,
512                backend: &plan.backend,
513                session_name: &session_name,
514                previous_session_name: previous_session_name.as_deref(),
515                snapshot_image: plan.snapshot_image.as_deref(),
516                resource_limits: &plan.resource_limits,
517                command: &plan.command,
518                message: &plan.message,
519            },
520            output_format,
521        )),
522        error: None,
523    }
524}
525
526#[cfg(test)]
527#[path = "execution_resume_cases.rs"]
528mod tests;