start-command 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +5 -0
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/command-builder.js +3 -0
  8. package/src/lib/docker-cleanup.js +12 -5
  9. package/src/lib/docker-command-handoff.js +75 -0
  10. package/src/lib/docker-labels.js +33 -0
  11. package/src/lib/docker-runtime-args.js +8 -0
  12. package/src/lib/docker-snapshot-safety.js +164 -0
  13. package/src/lib/execution-recovery.js +8 -1
  14. package/src/lib/execution-resume.js +184 -37
  15. package/src/lib/failure-handler.js +15 -5
  16. package/src/lib/isolation.js +20 -4
  17. package/src/lib/log-sanitizer.js +267 -0
  18. package/src/lib/log-uploader.js +38 -19
  19. package/src/lib/query-commands.js +4 -3
  20. package/src/lib/shell-utils.js +67 -17
  21. package/src/lib/usage.js +5 -1
  22. package/test/ci-workflow-invariants.js +15 -14
  23. package/test/docker-command-handoff.js +107 -0
  24. package/test/docker-runtime-options.js +4 -0
  25. package/test/docker-snapshot-safety.js +106 -0
  26. package/test/execution-resume.js +10 -8
  27. package/test/failure-handler.js +11 -0
  28. package/test/javascript-first-ci.js +216 -0
  29. package/test/log-sanitizer.js +306 -0
  30. package/test/regression-176.js +7 -0
  31. package/test/regression-187.js +9 -1
  32. package/test/regression-191.js +9 -1
  33. package/test/regression-193.js +11 -1
  34. package/test/regression-198.js +281 -0
  35. package/test/regression-199.js +79 -0
  36. package/test/regression-201.js +25 -0
  37. package/test/regression-202.js +92 -0
  38. package/test/regression-91.js +23 -20
  39. package/test/shared-golden.js +54 -0
  40. package/test/status-query.js +97 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # start-command
2
2
 
3
+ ## 0.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 94db203: Add copy-free Docker command resumes with guarded legacy snapshots, persistent
8
+ task labels, wrapper help flags, private sanitized log uploads, and faithful shell
9
+ argument and exit-code handling. Enforce JavaScript-first CI and shared Rust parity.
10
+
3
11
  ## 0.36.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -24,6 +24,8 @@ $ ls -la
24
24
  $ bun test
25
25
  $ git status
26
26
  $ --list
27
+ $ --help
28
+ $ -i docker -d --label task.tool=codex -- my-task
27
29
  $ --attach <id>
28
30
  $ --resume <id> -- <command>
29
31
  $ --resume-all
@@ -34,6 +36,10 @@ See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
34
36
  [docs/EXAMPLES.md](../docs/EXAMPLES.md) for the full user-facing guide and
35
37
  checked examples.
36
38
 
39
+ `-h` also prints usage. Labels persist across Docker resumes. New detached
40
+ containers support copy-free command handoff; legacy snapshots require capacity
41
+ preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
42
+
37
43
  ## Development
38
44
 
39
45
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -192,6 +192,10 @@ try {
192
192
  }
193
193
 
194
194
  const { wrapperOptions, command: parsedCommand } = parsedArgs;
195
+ if (wrapperOptions.help) {
196
+ printUsage();
197
+ process.exit(0);
198
+ }
195
199
 
196
200
  // Query/control modes (--status, --list, --attach, --resume, ...) address an
197
201
  // execution that already exists, so they never need a command of their own.
@@ -479,6 +483,7 @@ async function runWithIsolation(
479
483
  result = await runIsolated(environment, cmd, {
480
484
  ...options,
481
485
  session: sessionName,
486
+ sessionId,
482
487
  image: effectiveImage,
483
488
  endpoint: options.endpoint,
484
489
  detached: mode === 'detached',
@@ -26,6 +26,8 @@ function createQueryOptionDefaults() {
26
26
  list: false, // List all tracked execution records
27
27
  running: false, // Restrict --list to executions that are still running
28
28
  uploadLog: null, // UUID/session name whose stored log should be uploaded
29
+ noSanitize: false,
30
+ removeOriginal: false,
29
31
  outputFormat: null, // Output format (links-notation, json, text)
30
32
  stop: null, // UUID/session name to stop gracefully
31
33
  terminate: null, // UUID/session name to terminate immediately
@@ -55,6 +57,12 @@ const IDENTIFIER_OPTIONS = {
55
57
  * Boolean flags with no argument.
56
58
  */
57
59
  const FLAG_OPTIONS = {
60
+ '--no-sanitize': (options) => {
61
+ options.noSanitize = true;
62
+ },
63
+ '--remove-original': (options) => {
64
+ options.removeOriginal = true;
65
+ },
58
66
  '--list': (options) => {
59
67
  options.list = true;
60
68
  },
@@ -185,6 +193,12 @@ const OUTPUT_FORMAT_MODES = ['--status', '--list', '--resume', '--resume-all'];
185
193
  * @throws {Error} When the combination is not supported
186
194
  */
187
195
  function validateQueryOptions(options) {
196
+ if (options.noSanitize && !hasValue(options.uploadLog)) {
197
+ throw new Error('--no-sanitize is only valid with --upload-log');
198
+ }
199
+ if (options.removeOriginal && !hasValue(options.resume)) {
200
+ throw new Error('--remove-original is only valid with --resume');
201
+ }
188
202
  if (hasValue(options.outputFormat)) {
189
203
  if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
190
204
  throw new Error(
@@ -193,6 +193,8 @@ function parseArgs(args) {
193
193
  volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels
194
194
  mounts: [], // Docker --mount specs, applied to docker levels
195
195
  env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
196
+ labels: [], // Caller attribution, applied to every Docker level
197
+ help: false,
196
198
  privileged: false, // Run docker container in privileged mode
197
199
  network: null, // First Docker network name (compatibility accessor)
198
200
  networks: [], // Ordered Docker network names
@@ -247,7 +249,9 @@ function parseArgs(args) {
247
249
  }
248
250
 
249
251
  // Validate options
250
- validateOptions(wrapperOptions);
252
+ if (!wrapperOptions.help) {
253
+ validateOptions(wrapperOptions);
254
+ }
251
255
 
252
256
  return {
253
257
  wrapperOptions,
@@ -288,6 +292,16 @@ function parseWrapperArgs(args, options) {
288
292
  */
289
293
  function parseOption(args, index, options) {
290
294
  const arg = args[index];
295
+ if (arg === '--help' || arg === '-h') {
296
+ options.help = true;
297
+ return 1;
298
+ }
299
+ if (arg === '--label' || arg.startsWith('--label=')) {
300
+ const label = arg === '--label' ? args[index + 1] : arg.slice(8);
301
+ require('./docker-labels').validateLabel(label);
302
+ options.labels.push(label);
303
+ return arg === '--label' ? 2 : 1;
304
+ }
291
305
 
292
306
  // --isolated, --isolation, or -i
293
307
  if (arg === '--isolated' || arg === '--isolation' || arg === '-i') {
@@ -568,6 +582,11 @@ function parseOption(args, index, options) {
568
582
  * @throws {Error} If a docker-only option is set without docker isolation
569
583
  */
570
584
  function validateDockerRuntimeOptionsRequireDocker(options) {
585
+ if (options.labels?.length > 0 && !options.resume) {
586
+ throw new Error(
587
+ '--label option is only valid with docker isolation or --resume'
588
+ );
589
+ }
571
590
  if (options.volumes && options.volumes.length > 0) {
572
591
  throw new Error(
573
592
  '--volume option is only valid when isolation stack includes docker'
@@ -106,6 +106,9 @@ function buildNextLevelCommand(options, command) {
106
106
  for (const envVar of options.env || []) {
107
107
  parts.push(`--env "${envVar}"`);
108
108
  }
109
+ for (const label of options.labels || []) {
110
+ parts.push(`--label ${require('./shell-utils').quoteShellArg(label)}`);
111
+ }
109
112
  if (options.privileged) {
110
113
  parts.push('--privileged');
111
114
  }
@@ -83,7 +83,7 @@ function getDockerContainerCleanupInstructions(containerName) {
83
83
  `Container kept for investigation: ${containerName}`,
84
84
  `Re-enter while running: $ --attach ${containerName}`,
85
85
  `Continue the stored command: $ --resume ${containerName}`,
86
- `Run another command in the same container: $ --resume ${containerName} -- <command>`,
86
+ `Run another command (legacy containers snapshot the filesystem into a new container): $ --resume ${containerName} -- <command>`,
87
87
  `Remove when done: docker rm -f ${containerName}`,
88
88
  ].join('\n');
89
89
  }
@@ -306,7 +306,7 @@ function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
306
306
  `printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
307
307
  `Re-enter while running: $ --attach %s\\n` +
308
308
  `Continue the stored command: $ --resume %s\\n` +
309
- `Run another command in the same container: $ --resume %s -- <command>\\n` +
309
+ `Run another command (legacy containers snapshot the filesystem into a new container): $ --resume %s -- <command>\\n` +
310
310
  `Remove when done: docker rm -f %s\\n' ` +
311
311
  `${quotedName} "$__start_command_reason" ` +
312
312
  `${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
@@ -361,6 +361,11 @@ function buildDetachedDockerCompletionScript(
361
361
  watcherOptions = {}
362
362
  ) {
363
363
  const quotedName = shellQuote(containerName);
364
+ const imageCleanup =
365
+ require('./docker-snapshot-safety').snapshotImageCleanupSnippet(
366
+ containerName,
367
+ logPath ? `>> ${shellQuote(logPath)} 2>&1` : '>/dev/null 2>&1'
368
+ );
364
369
  // The container's cgroup disappears with it, so its memory counters are
365
370
  // sampled while it runs (issue #182).
366
371
  const cpuMonitor = require('./cpu-penalty-monitor');
@@ -389,7 +394,7 @@ function buildDetachedDockerCompletionScript(
389
394
  quotedLogPath
390
395
  );
391
396
  const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
392
- const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}; ${memory}`;
397
+ const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
393
398
  const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
394
399
 
395
400
  const since = watcherOptions.since
@@ -438,13 +443,15 @@ function buildDetachedDockerCompletionScript(
438
443
  parts.push(buildCgroupSamplerStopSnippet());
439
444
  parts.push(buildDockerStateSnippet(containerName));
440
445
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
441
- exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
446
+ exited.push(
447
+ `${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi`
448
+ );
442
449
  } else if (
443
450
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
444
451
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
445
452
  ) {
446
453
  exited.push(
447
- `if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
454
+ `if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi; fi`
448
455
  );
449
456
  }
450
457
  }
@@ -0,0 +1,75 @@
1
+ /** Copy only a command script into a stopped container, never its writable layer. */
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { shellQuote } = require('./isolation-log-utils');
6
+
7
+ function commandHandoffPath(sessionName) {
8
+ return `/.start-command-resume-${String(sessionName).replace(/[^A-Za-z0-9_.-]/g, '_')}`;
9
+ }
10
+
11
+ function buildCommandHandoffArgs(mainArgs, markerPath) {
12
+ return [
13
+ 'sh',
14
+ '-c',
15
+ 'p=$1; shift; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
16
+ 'start-command-handoff',
17
+ markerPath,
18
+ ...mainArgs,
19
+ ];
20
+ }
21
+
22
+ function buildCommandHandoffScript(command, options = {}) {
23
+ const {
24
+ isInteractiveShellCommand,
25
+ isShellInvocationWithArgs,
26
+ buildShellWithArgsCmdArgs,
27
+ toShellWords,
28
+ } = require('./shell-utils');
29
+ if (
30
+ !options.keepAlive &&
31
+ (isInteractiveShellCommand(command) || isShellInvocationWithArgs(command))
32
+ ) {
33
+ const args = isShellInvocationWithArgs(command)
34
+ ? buildShellWithArgsCmdArgs(command)
35
+ : toShellWords(command);
36
+ return `exec ${args.map(shellQuote).join(' ')}\n`;
37
+ }
38
+ if (!options.shell || options.shell === 'auto') {
39
+ const effective = options.keepAlive
40
+ ? `${command}; exec "$__START_COMMAND_HANDOFF_SHELL"`
41
+ : command;
42
+ return `__START_COMMAND_HANDOFF_SHELL=$(command -v bash || command -v zsh || command -v sh); export __START_COMMAND_HANDOFF_SHELL\ncase "$__START_COMMAND_HANDOFF_SHELL" in */bash|bash|*/zsh|zsh) exec "$__START_COMMAND_HANDOFF_SHELL" -i -c ${shellQuote(effective)};; *) exec "$__START_COMMAND_HANDOFF_SHELL" -c ${shellQuote(effective)};; esac\n`;
43
+ }
44
+ const shell = options.shell;
45
+ const interactive = /(?:^|\/)(?:bash|zsh)$/.test(shell) ? ' -i' : '';
46
+ const effective = options.keepAlive
47
+ ? `${command}; exec ${shellQuote(shell)}`
48
+ : command;
49
+ return `exec ${shellQuote(shell)}${interactive} -c ${shellQuote(effective)}\n`;
50
+ }
51
+
52
+ function writeCommandHandoff(containerName, command, runner, options = {}) {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-handoff-'));
54
+ const file = path.join(dir, 'command');
55
+ try {
56
+ fs.writeFileSync(file, buildCommandHandoffScript(command, options), {
57
+ mode: 0o644,
58
+ });
59
+ fs.chmodSync(file, 0o644);
60
+ return runner(require('./docker-cleanup').getDockerCommand(), [
61
+ 'cp',
62
+ file,
63
+ `${containerName}:${commandHandoffPath(containerName)}`,
64
+ ]);
65
+ } finally {
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ }
69
+
70
+ module.exports = {
71
+ commandHandoffPath,
72
+ buildCommandHandoffArgs,
73
+ buildCommandHandoffScript,
74
+ writeCommandHandoff,
75
+ };
@@ -0,0 +1,33 @@
1
+ /** Docker attribution shared by initial launches and snapshot resumes (#199). */
2
+ function validateLabel(label) {
3
+ if (typeof label !== 'string' || !/^[^=\s]+=[\s\S]*$/.test(label)) {
4
+ throw new Error('--label requires KEY=VALUE with a nonempty key');
5
+ }
6
+ if (label.startsWith('start-command.')) {
7
+ throw new Error(
8
+ 'start-command.* labels are reserved for execution attribution'
9
+ );
10
+ }
11
+ }
12
+
13
+ function dockerLabels(options = {}) {
14
+ const labels = [...(options.labels || [])];
15
+ const session = options.containerName || options.session;
16
+ if (session) {
17
+ const inferredRoot =
18
+ Number(options.resumeCount) > 0
19
+ ? session.replace(/(?:-resume-\d+)+$/, '')
20
+ : session;
21
+ labels.push(`start-command.session=${session}`);
22
+ labels.push(
23
+ `start-command.root-session=${options.rootSession || inferredRoot}`
24
+ );
25
+ labels.push(`start-command.resume-count=${options.resumeCount || 0}`);
26
+ }
27
+ if (options.sessionId || options.uuid) {
28
+ labels.push(`start-command.uuid=${options.sessionId || options.uuid}`);
29
+ }
30
+ return labels;
31
+ }
32
+
33
+ module.exports = { validateLabel, dockerLabels };
@@ -42,6 +42,9 @@ function buildDockerRuntimeArgs(options = {}) {
42
42
  for (const envVar of options.env || []) {
43
43
  args.push('-e', envVar);
44
44
  }
45
+ for (const label of require('./docker-labels').dockerLabels(options)) {
46
+ args.push('--label', label);
47
+ }
45
48
  for (const volume of options.volumes || []) {
46
49
  args.push('-v', volume);
47
50
  }
@@ -69,6 +72,9 @@ function buildDockerRuntimeArgs(options = {}) {
69
72
  */
70
73
  function buildDockerRuntimeStatusLines(options = {}) {
71
74
  const lines = [];
75
+ if (options.labels && options.labels.length > 0) {
76
+ lines.push(`[Isolation] Labels: ${options.labels.join(', ')}`);
77
+ }
72
78
  if (options.volumes && options.volumes.length > 0) {
73
79
  lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
74
80
  }
@@ -129,6 +135,8 @@ function buildDockerRuntimeMetadata(options = {}) {
129
135
  options.volumes && options.volumes.length > 0 ? options.volumes : null,
130
136
  mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
131
137
  env: options.env && options.env.length > 0 ? options.env : null,
138
+ labels: options.labels && options.labels.length > 0 ? options.labels : null,
139
+ commandHandoff: options.detached ? true : null,
132
140
  privileged: options.privileged || null,
133
141
  network: networks[0] || null,
134
142
  networks: networks.length > 0 ? networks : null,
@@ -0,0 +1,164 @@
1
+ /** Serialize costly snapshot resumes and check every image-store filesystem. */
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { LockManager } = require('./store-lock');
6
+
7
+ const GiB = 1024 ** 3;
8
+ const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
9
+ const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
10
+
11
+ function acquireSnapshotLock(options = {}) {
12
+ const lockPath =
13
+ options.lockPath ||
14
+ process.env.START_SNAPSHOT_LOCK ||
15
+ path.join(
16
+ process.platform === 'win32' ? os.tmpdir() : '/tmp',
17
+ 'start-command-docker-snapshot.lock'
18
+ );
19
+ const lock = new LockManager(lockPath);
20
+ // The ordinary store lock is held only for a brief write. A snapshot can
21
+ // legitimately take hours, so never reclaim this lock merely for its age.
22
+ lock.isLockStale = (data) => {
23
+ if (
24
+ !data ||
25
+ data.hostname !== os.hostname() ||
26
+ !Number.isInteger(data.pid) ||
27
+ data.pid <= 0
28
+ ) {
29
+ return false;
30
+ }
31
+ try {
32
+ process.kill(data.pid, 0);
33
+ return false;
34
+ } catch (error) {
35
+ return error.code === 'ESRCH';
36
+ }
37
+ };
38
+ if (!lock.acquire(1000)) {
39
+ throw new Error(
40
+ 'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
41
+ );
42
+ }
43
+ return lock;
44
+ }
45
+
46
+ function containerdRoot(config = '') {
47
+ // Only the root-level TOML key configures containerd's storage root.
48
+ const topLevel = config.split(/^\s*\[/m)[0];
49
+ return (
50
+ topLevel.match(/^\s*root\s*=\s*["']([^"']+)["']/m)?.[1] ||
51
+ '/var/lib/containerd'
52
+ );
53
+ }
54
+
55
+ function defaultContainerdRoot() {
56
+ if (process.env.START_CONTAINERD_ROOT) {
57
+ return process.env.START_CONTAINERD_ROOT;
58
+ }
59
+ try {
60
+ return containerdRoot(
61
+ fs.readFileSync('/etc/containerd/config.toml', 'utf8')
62
+ );
63
+ } catch (error) {
64
+ if (error.code === 'ENOENT') {
65
+ return containerdRoot();
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function readFreeBytes(root) {
72
+ const stats = fs.statfsSync(root, { bigint: true });
73
+ return Number(stats.bavail * stats.bsize);
74
+ }
75
+
76
+ function preflightSnapshot(containerName, runner, options = {}) {
77
+ const docker = require('./docker-cleanup').getDockerCommand();
78
+ const sizeResult = runner(docker, [
79
+ 'inspect',
80
+ '--size',
81
+ '--format',
82
+ '{{.SizeRw}}',
83
+ containerName,
84
+ ]);
85
+ const size = Number(String(sizeResult.stdout || '').trim());
86
+ if (
87
+ !sizeResult.success ||
88
+ !/^\d+$/.test(String(sizeResult.stdout || '').trim()) ||
89
+ !Number.isSafeInteger(size) ||
90
+ size < 0
91
+ ) {
92
+ throw new Error(
93
+ 'Cannot determine the writable layer size; refusing Docker snapshot resume.'
94
+ );
95
+ }
96
+ const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
97
+ let info;
98
+ try {
99
+ info = JSON.parse(infoResult.stdout);
100
+ } catch {
101
+ /* Refuse unknown storage. */
102
+ }
103
+ if (!infoResult.success || !info?.DockerRootDir) {
104
+ throw new Error(
105
+ 'Cannot determine Docker data root; refusing Docker snapshot resume.'
106
+ );
107
+ }
108
+ const roots = [info.DockerRootDir];
109
+ if (
110
+ JSON.stringify(info.DriverStatus || []).includes(
111
+ 'io.containerd.snapshotter'
112
+ )
113
+ ) {
114
+ roots.push(options.containerdRoot || defaultContainerdRoot());
115
+ }
116
+ const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
117
+ const free = options.freeBytes || readFreeBytes;
118
+ for (const root of [...new Set(roots)]) {
119
+ let available;
120
+ try {
121
+ available = free(root);
122
+ } catch (error) {
123
+ throw new Error(
124
+ `Cannot check free disk at ${root}: ${error.message}; refusing Docker snapshot resume.`,
125
+ { cause: error }
126
+ );
127
+ }
128
+ if (!Number.isFinite(available) || available < required) {
129
+ throw new Error(
130
+ `Insufficient disk at ${root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(available / GiB).toFixed(2)} GiB. Docker snapshot resume refused.`
131
+ );
132
+ }
133
+ }
134
+ return {
135
+ size,
136
+ required,
137
+ roots,
138
+ message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
139
+ };
140
+ }
141
+
142
+ function snapshotImageCleanupSnippet(
143
+ containerName,
144
+ redirection = '>/dev/null 2>&1'
145
+ ) {
146
+ const { shellQuote } = require('./isolation-log-utils');
147
+ const name = shellQuote(containerName);
148
+ const format = shellQuote(
149
+ `{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`
150
+ );
151
+ return {
152
+ capture: `__start_snapshot_image=$(docker inspect -f ${format} ${name} 2>/dev/null || true)`,
153
+ remove: `case "$__start_snapshot_image" in start-command-resume/*) docker rmi "$__start_snapshot_image" ${redirection} || true;; esac`,
154
+ };
155
+ }
156
+
157
+ module.exports = {
158
+ acquireSnapshotLock,
159
+ containerdRoot,
160
+ preflightSnapshot,
161
+ SNAPSHOT_RESERVE_BYTES,
162
+ SNAPSHOT_IMAGE_LABEL,
163
+ snapshotImageCleanupSnippet,
164
+ };
@@ -357,7 +357,14 @@ function recoverKilledExecution(params = {}) {
357
357
  if (opts.recoveryCommand) {
358
358
  let copied;
359
359
  try {
360
- copied = writeRecoveryMarker(containerName, attempt, runner);
360
+ copied = opts.commandHandoff
361
+ ? require('./docker-command-handoff').writeCommandHandoff(
362
+ containerName,
363
+ `export ${RECOVERY_ATTEMPT_ENV}=${attempt}; ${opts.recoveryCommand}`,
364
+ runner,
365
+ opts
366
+ )
367
+ : writeRecoveryMarker(containerName, attempt, runner);
361
368
  } catch (err) {
362
369
  copied = { success: false, error: err.message };
363
370
  }