start-command 0.37.7 → 0.37.9

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Steps around the `docker commit` of a snapshot resume (issue #224):
3
+ *
4
+ * - `--snapshot-prepare <command>` runs in the stopped original before the
5
+ * commit: `docker start`, `docker exec ... sh -c <command>`, and always a
6
+ * `docker kill` afterwards. Starting the original runs its own command
7
+ * again, so this is only for containers whose start is known to be safe.
8
+ * - Progress lines while `docker commit` runs, which prints nothing itself.
9
+ * - `--remove-original-after <seconds>`: remove the stopped original only if
10
+ * the replacement is still running after the delay.
11
+ */
12
+ const { spawn } = require('child_process');
13
+ const { setInterval, clearInterval, setTimeout } = globalThis;
14
+ const { appendLogFile } = require('./isolation-log-utils');
15
+ const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
16
+
17
+ const DEFAULT_PROGRESS_INTERVAL_SECONDS = 60;
18
+ /** Upper bound for one `--snapshot-prepare`; the original is killed after. */
19
+ const DEFAULT_PREPARE_TIMEOUT_SECONDS = 600;
20
+
21
+ /**
22
+ * Format a duration the way the progress lines print it: "45s", "12 min".
23
+ * @param {number} seconds - Elapsed seconds
24
+ * @returns {string} Human-readable duration
25
+ */
26
+ function formatElapsed(seconds) {
27
+ const whole = Math.max(0, Math.floor(seconds));
28
+ if (whole < 60) {
29
+ return `${whole}s`;
30
+ }
31
+ const minutes = Math.floor(whole / 60);
32
+ const rest = whole % 60;
33
+ return rest ? `${minutes} min ${rest}s` : `${minutes} min`;
34
+ }
35
+
36
+ function positiveSeconds(value, fallback) {
37
+ const seconds = Number(value);
38
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
39
+ }
40
+
41
+ function progressIntervalSeconds(env = process.env) {
42
+ return positiveSeconds(
43
+ env.START_SNAPSHOT_PROGRESS_INTERVAL,
44
+ DEFAULT_PROGRESS_INTERVAL_SECONDS
45
+ );
46
+ }
47
+
48
+ /** Report one line to the execution log and through `notify`. */
49
+ function report(context, line) {
50
+ if (context.logPath) {
51
+ appendLogFile(context.logPath, `${line}\n`);
52
+ }
53
+ context.notify(line);
54
+ }
55
+
56
+ /**
57
+ * Run a command without blocking the event loop.
58
+ * @returns {Promise<{success, stdout, stderr, status, error}>}
59
+ */
60
+ function runCommandAsync(command, args) {
61
+ return new Promise((resolve) => {
62
+ const options = { stdio: ['ignore', 'pipe', 'pipe'] };
63
+ const child = spawn(
64
+ command,
65
+ args,
66
+ command === getDockerCommand() ? getDockerSpawnOptions(options) : options
67
+ );
68
+ let stdout = '';
69
+ let stderr = '';
70
+ child.stdout.on('data', (chunk) => {
71
+ stdout += chunk;
72
+ });
73
+ child.stderr.on('data', (chunk) => {
74
+ stderr += chunk;
75
+ });
76
+ child.on('error', (error) => {
77
+ resolve({
78
+ success: false,
79
+ stdout,
80
+ stderr,
81
+ status: null,
82
+ error: error.message,
83
+ });
84
+ });
85
+ child.on('close', (status) => {
86
+ resolve({ success: status === 0, stdout, stderr, status, error: null });
87
+ });
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Run `docker commit` and report the elapsed time every interval.
93
+ * @param {{command: string, args: string[]}} step - The commit step
94
+ * @param {object} context - {sessionName, logPath, notify, runAsync,
95
+ * intervalSeconds}
96
+ * @returns {Promise<object>} The runner result
97
+ */
98
+ async function commitWithProgress(step, context) {
99
+ const started = Date.now();
100
+ const intervalSeconds = context.intervalSeconds ?? progressIntervalSeconds();
101
+ const timer = setInterval(() => {
102
+ const elapsed = formatElapsed((Date.now() - started) / 1000);
103
+ report(
104
+ context,
105
+ `[Resume] snapshotting ${context.sessionName}: ${elapsed} elapsed (docker commit reports no progress)`
106
+ );
107
+ }, intervalSeconds * 1000);
108
+ let result;
109
+ try {
110
+ result = await context.runAsync(step.command, step.args);
111
+ } finally {
112
+ clearInterval(timer);
113
+ }
114
+ if (result && result.success) {
115
+ report(
116
+ context,
117
+ `[Resume] snapshot of ${context.sessionName} committed after ${formatElapsed((Date.now() - started) / 1000)}`
118
+ );
119
+ }
120
+ return result;
121
+ }
122
+
123
+ function detail(result, fallback) {
124
+ return (
125
+ String(result?.stderr || '').trim() ||
126
+ result?.error ||
127
+ (result?.status !== undefined && result?.status !== null
128
+ ? `exit code ${result.status}`
129
+ : fallback)
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Run `--snapshot-prepare` in the stopped original. A failure is reported and
135
+ * the snapshot continues with the files as they are.
136
+ * @param {string} sessionName - Original container
137
+ * @param {string} prepare - Shell command run with `sh -c`
138
+ * @param {Function} runner - Sync command runner
139
+ * @param {object} context - {logPath, notify}
140
+ * @returns {{success: boolean, message: string}}
141
+ */
142
+ function runSnapshotPrepare(sessionName, prepare, runner, context) {
143
+ const docker = getDockerCommand();
144
+ const started = runner(docker, ['start', sessionName]);
145
+ if (!started.success) {
146
+ const message = `[Resume] snapshot prepare skipped: could not start ${sessionName}: ${detail(started, 'docker start failed')}`;
147
+ report(context, message);
148
+ return { success: false, message };
149
+ }
150
+ let result;
151
+ try {
152
+ const timeout =
153
+ positiveSeconds(
154
+ process.env.START_SNAPSHOT_PREPARE_TIMEOUT,
155
+ DEFAULT_PREPARE_TIMEOUT_SECONDS
156
+ ) * 1000;
157
+ result = runner(docker, ['exec', sessionName, 'sh', '-c', prepare], {
158
+ timeout,
159
+ });
160
+ } catch (error) {
161
+ result = { success: false, error: error.message };
162
+ } finally {
163
+ // Never commit a running original, and never leave it running.
164
+ runner(docker, ['kill', sessionName]);
165
+ runner(docker, ['wait', sessionName]);
166
+ }
167
+ const success = Boolean(result.success);
168
+ const message = success
169
+ ? `[Resume] snapshot prepare finished in ${sessionName}`
170
+ : `[Resume] snapshot prepare failed in ${sessionName} (${detail(result, 'docker exec failed')}); snapshotting the files as they are`;
171
+ report(context, message);
172
+ return { success, message };
173
+ }
174
+
175
+ function sleep(ms) {
176
+ return new Promise((resolve) => setTimeout(resolve, ms));
177
+ }
178
+
179
+ /**
180
+ * Remove the stopped original once the replacement has survived the delay.
181
+ * @param {object} plan - Resume plan with sessionName and newSessionName
182
+ * @param {number} delaySeconds - Startup window
183
+ * @param {Function} runner - Sync command runner
184
+ * @param {object} context - {logPath, notify, sleep}
185
+ * @returns {Promise<string>} The note describing what happened
186
+ */
187
+ async function removeOriginalAfter(plan, delaySeconds, runner, context) {
188
+ report(
189
+ context,
190
+ `[Resume] Checking ${plan.newSessionName} again in ${formatElapsed(delaySeconds)} before removing ${plan.sessionName}`
191
+ );
192
+ await (context.sleep || sleep)(delaySeconds * 1000);
193
+ const docker = getDockerCommand();
194
+ const running = runner(docker, [
195
+ 'inspect',
196
+ '-f',
197
+ '{{.State.Running}}',
198
+ plan.newSessionName,
199
+ ]);
200
+ let note;
201
+ if (running.success && String(running.stdout).trim() === 'true') {
202
+ const removed = runner(docker, ['rm', plan.sessionName]);
203
+ note = removed.success
204
+ ? `Stopped original container removed: ${plan.sessionName}`
205
+ : `Original container retained: ${detail(removed, 'docker rm failed')}`;
206
+ } else {
207
+ note = `Original container retained: the snapshot container is not running ${formatElapsed(delaySeconds)} after launch.`;
208
+ }
209
+ if (context.logPath) {
210
+ appendLogFile(context.logPath, `${note}\n`);
211
+ }
212
+ return note;
213
+ }
214
+
215
+ module.exports = {
216
+ commitWithProgress,
217
+ formatElapsed,
218
+ progressIntervalSeconds,
219
+ removeOriginalAfter,
220
+ runCommandAsync,
221
+ runSnapshotPrepare,
222
+ };
@@ -8,6 +8,30 @@ const GiB = 1024 ** 3;
8
8
  const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
9
9
  const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
10
10
 
11
+ /** sysexits.h EX_TEMPFAIL: the resume can succeed when retried later. */
12
+ const EX_TEMPFAIL = 75;
13
+
14
+ function tempfail(message) {
15
+ const error = new Error(message);
16
+ error.exitCode = EX_TEMPFAIL;
17
+ return error;
18
+ }
19
+
20
+ function formatSeconds(seconds) {
21
+ return `${Number(seconds.toFixed(3))}s`;
22
+ }
23
+
24
+ function notifier(options) {
25
+ return options.notify || ((line) => process.stderr.write(`${line}\n`));
26
+ }
27
+
28
+ /**
29
+ * Take the host-wide snapshot lock. With `waitSeconds` above 0 a busy lock is
30
+ * waited for (issue #224); the wait is announced once with the holder's name.
31
+ * @param {object} [options] - {lockPath, holder, waitSeconds, notify}
32
+ * @returns {LockManager} The held lock
33
+ * @throws {Error} With `exitCode` 75 when the lock stays busy
34
+ */
11
35
  function acquireSnapshotLock(options = {}) {
12
36
  const lockPath =
13
37
  options.lockPath ||
@@ -17,6 +41,9 @@ function acquireSnapshotLock(options = {}) {
17
41
  'start-command-docker-snapshot.lock'
18
42
  );
19
43
  const lock = new LockManager(lockPath);
44
+ if (options.holder) {
45
+ lock.metadata = { holder: options.holder };
46
+ }
20
47
  // The ordinary store lock is held only for a brief write. A snapshot can
21
48
  // legitimately take hours, so never reclaim this lock merely for its age.
22
49
  lock.isLockStale = (data) => {
@@ -35,12 +62,28 @@ function acquireSnapshotLock(options = {}) {
35
62
  return error.code === 'ESRCH';
36
63
  }
37
64
  };
38
- if (!lock.acquire(1000)) {
39
- throw new Error(
40
- 'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
65
+ const waitMs = Math.max(0, (options.waitSeconds || 0) * 1000);
66
+ if (lock.acquire(Math.min(1000, waitMs || 1000))) {
67
+ return lock;
68
+ }
69
+ const holder = lock.readLockFile()?.holder;
70
+ const busy = holder
71
+ ? `A Docker snapshot resume of ${holder} is in progress on this host.`
72
+ : 'A Docker snapshot resume is in progress on this host.';
73
+ if (waitMs <= 1000) {
74
+ throw tempfail(
75
+ `${busy} Retry after it finishes, or wait for it with --snapshot-wait <seconds>.`
41
76
  );
42
77
  }
43
- return lock;
78
+ notifier(options)(
79
+ `[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for the snapshot of ${holder || 'another container'} to finish before snapshotting ${options.holder || 'this container'}.`
80
+ );
81
+ if (lock.acquire(waitMs - 1000)) {
82
+ return lock;
83
+ }
84
+ throw tempfail(
85
+ `${busy} Still busy after waiting ${formatSeconds(waitMs / 1000)}; retry after it finishes.`
86
+ );
44
87
  }
45
88
 
46
89
  function containerdRoot(config = '') {
@@ -73,8 +116,10 @@ function readFreeBytes(root) {
73
116
  return Number(stats.bavail * stats.bsize);
74
117
  }
75
118
 
76
- function preflightSnapshot(containerName, runner, options = {}) {
77
- const docker = require('./docker-cleanup').getDockerCommand();
119
+ function measureWritableLayer(containerName, runner, docker, options) {
120
+ if (options.sizeHint !== null && options.sizeHint !== undefined) {
121
+ return { size: options.sizeHint, source: 'size hint' };
122
+ }
78
123
  const sizeResult = runner(docker, [
79
124
  'inspect',
80
125
  '--size',
@@ -93,6 +138,10 @@ function preflightSnapshot(containerName, runner, options = {}) {
93
138
  'Cannot determine the writable layer size; refusing Docker snapshot resume.'
94
139
  );
95
140
  }
141
+ return { size, source: null };
142
+ }
143
+
144
+ function imageStoreRoots(runner, docker, options) {
96
145
  const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
97
146
  let info;
98
147
  try {
@@ -113,9 +162,12 @@ function preflightSnapshot(containerName, runner, options = {}) {
113
162
  ) {
114
163
  roots.push(options.containerdRoot || defaultContainerdRoot());
115
164
  }
116
- const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
117
- const free = options.freeBytes || readFreeBytes;
118
- for (const root of [...new Set(roots)]) {
165
+ return [...new Set(roots)];
166
+ }
167
+
168
+ /** @returns {?object} The first root without enough space, or null */
169
+ function findShortRoot(roots, required, free) {
170
+ for (const root of roots) {
119
171
  let available;
120
172
  try {
121
173
  available = free(root);
@@ -126,16 +178,75 @@ function preflightSnapshot(containerName, runner, options = {}) {
126
178
  );
127
179
  }
128
180
  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.`
181
+ return { root, available };
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function sleepSync(ms) {
188
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
189
+ }
190
+
191
+ /**
192
+ * Refuse a snapshot that could fill the disk. The writable layer is measured
193
+ * once (or taken from `sizeHint`); with `diskWaitSeconds` the free space is
194
+ * re-checked every `diskPollSeconds` until it suffices (issue #224).
195
+ * @param {object} [options] - {sizeHint, diskWaitSeconds, diskPollSeconds,
196
+ * reserveBytes, freeBytes, containerdRoot, notify, sleep, now}
197
+ * @throws {Error} With `exitCode` 75 when the disk stays short
198
+ */
199
+ function preflightSnapshot(containerName, runner, options = {}) {
200
+ const docker = require('./docker-cleanup').getDockerCommand();
201
+ const { size, source } = measureWritableLayer(
202
+ containerName,
203
+ runner,
204
+ docker,
205
+ options
206
+ );
207
+ const roots = imageStoreRoots(runner, docker, options);
208
+ const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
209
+ const free = options.freeBytes || readFreeBytes;
210
+ const waitMs = Math.max(0, (options.diskWaitSeconds || 0) * 1000);
211
+ const pollMs = Math.max(
212
+ 1,
213
+ (options.diskPollSeconds ??
214
+ Number(process.env.START_SNAPSHOT_DISK_POLL || 10)) * 1000
215
+ );
216
+ const now = options.now || Date.now;
217
+ const sleep = options.sleep || sleepSync;
218
+ const started = now();
219
+ let waited = false;
220
+ for (;;) {
221
+ const short = findShortRoot(roots, required, free);
222
+ if (!short) {
223
+ break;
224
+ }
225
+ const detail = `Insufficient disk at ${short.root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(short.available / GiB).toFixed(2)} GiB.`;
226
+ const left = waitMs - (now() - started);
227
+ if (left <= 0) {
228
+ throw tempfail(
229
+ waited
230
+ ? `${detail} Still short after waiting ${formatSeconds(waitMs / 1000)}; Docker snapshot resume refused.`
231
+ : `${detail} Docker snapshot resume refused.`
232
+ );
233
+ }
234
+ if (!waited) {
235
+ notifier(options)(
236
+ `[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for disk: ${detail}`
131
237
  );
238
+ waited = true;
132
239
  }
240
+ sleep(Math.min(pollMs, left));
133
241
  }
242
+ const measured = source
243
+ ? `${(size / GiB).toFixed(2)} GiB (${source})`
244
+ : `${(size / GiB).toFixed(2)} GiB`;
134
245
  return {
135
246
  size,
136
247
  required,
137
248
  roots,
138
- message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
249
+ message: `[Resume] snapshotting ${measured}; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
139
250
  };
140
251
  }
141
252
 
@@ -155,6 +266,7 @@ function snapshotImageCleanupSnippet(
155
266
  }
156
267
 
157
268
  module.exports = {
269
+ EX_TEMPFAIL,
158
270
  acquireSnapshotLock,
159
271
  containerdRoot,
160
272
  preflightSnapshot,
@@ -7,6 +7,8 @@ const TERMINAL_FIELDS = [
7
7
  'observedAt',
8
8
  'staleDetectedAt',
9
9
  'containerStartedAt',
10
+ 'containerState',
11
+ 'containerRemovedObservedAt',
10
12
  'exitReason',
11
13
  'oomKilled',
12
14
  'memoryExhausted',
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `$ --cleanup <uuid-or-session-name>` (issue #224): remove the Docker
3
+ * containers of one execution and the snapshot images they were derived from.
4
+ *
5
+ * For callers that remove containers themselves: start-command's completion
6
+ * watcher removes a snapshot image only when it removes the container. Nothing
7
+ * here is forced, so Docker keeps any container that is still running and any
8
+ * image that something else still uses; those are reported as retained.
9
+ *
10
+ * The containers are the current one and every earlier container of a
11
+ * `--resume` chain (`sessionNameHistory`). The images are the
12
+ * `start-command-resume/*` images named by the `start-command.snapshot-image`
13
+ * label of those containers, by the record, and by the `-resume-N` names of
14
+ * the chain, removed newest first.
15
+ */
16
+ const { getDockerCommand } = require('./docker-cleanup');
17
+ const { runCommand } = require('./execution-control');
18
+ const { SNAPSHOT_IMAGE_LABEL } = require('./docker-snapshot-safety');
19
+
20
+ const SNAPSHOT_IMAGE_PATTERN = /^start-command-resume\/[a-z0-9._-]+:\d+$/;
21
+ const NOT_FOUND_PATTERN = /no such (container|image|object)/i;
22
+
23
+ /** Docker image name for snapshot attempt `attempt` of `sessionName`. */
24
+ function snapshotImageName(sessionName, attempt) {
25
+ const { buildSnapshotImageName } = require('./execution-resume');
26
+ return buildSnapshotImageName(sessionName, attempt);
27
+ }
28
+
29
+ /** Snapshot images implied by a `<name>-resume-<N>` chain, newest first. */
30
+ function chainImages(containerName) {
31
+ const images = [];
32
+ let current = String(containerName || '');
33
+ for (
34
+ let match = /^(.+)-resume-(\d+)$/.exec(current);
35
+ match;
36
+ match = /^(.+)-resume-(\d+)$/.exec(current)
37
+ ) {
38
+ images.push(snapshotImageName(match[1], match[2]));
39
+ current = match[1];
40
+ }
41
+ return images;
42
+ }
43
+
44
+ function attemptOf(image) {
45
+ return Number(/:(\d+)$/.exec(image)?.[1] || 0);
46
+ }
47
+
48
+ function errorText(result) {
49
+ return (
50
+ String(result?.stderr || '').trim() ||
51
+ result?.error ||
52
+ `exit code ${result?.status}`
53
+ );
54
+ }
55
+
56
+ /**
57
+ * The containers and images `--cleanup <execution>` would remove.
58
+ * @param {object} record - Execution record
59
+ * @param {Function} runner - Sync command runner
60
+ * @returns {{containers: string[], images: string[]}}
61
+ */
62
+ function planExecutionCleanup(record, runner) {
63
+ const options = record.options || {};
64
+ const current = options.sessionName || options.containerId || null;
65
+ const containers = [
66
+ ...new Set(
67
+ [current, ...[...(options.sessionNameHistory || [])].reverse()].filter(
68
+ Boolean
69
+ )
70
+ ),
71
+ ];
72
+ const images = [options.snapshotImage];
73
+ for (const container of containers) {
74
+ const labelled = runner(getDockerCommand(), [
75
+ 'inspect',
76
+ '-f',
77
+ `{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`,
78
+ container,
79
+ ]);
80
+ if (labelled.success) {
81
+ images.push(String(labelled.stdout || '').trim());
82
+ }
83
+ images.push(...chainImages(container));
84
+ }
85
+ return {
86
+ containers,
87
+ images: [...new Set(images)]
88
+ .filter((image) => SNAPSHOT_IMAGE_PATTERN.test(image || ''))
89
+ .sort((a, b) => attemptOf(b) - attemptOf(a)),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Remove (or, with `dryRun`, list) one object without force.
95
+ * @returns {{name: string, kind: string, state: string, detail?: string}}
96
+ * `state` is removed, absent, retained or present (dry run)
97
+ */
98
+ function removeObject(kind, name, runner, dryRun) {
99
+ const docker = getDockerCommand();
100
+ const args = dryRun
101
+ ? kind === 'image'
102
+ ? ['image', 'inspect', '--format', '{{.Id}}', name]
103
+ : ['container', 'inspect', '--format', '{{.Id}}', name]
104
+ : kind === 'image'
105
+ ? ['rmi', name]
106
+ : ['rm', name];
107
+ const result = runner(docker, args);
108
+ if (result.success) {
109
+ return { kind, name, state: dryRun ? 'present' : 'removed' };
110
+ }
111
+ const detail = errorText(result);
112
+ if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
113
+ return { kind, name, state: 'absent' };
114
+ }
115
+ return { kind, name, state: 'retained', detail };
116
+ }
117
+
118
+ const LABELS = {
119
+ removed: 'Removed',
120
+ absent: 'Already gone:',
121
+ retained: 'Retained',
122
+ present: 'Would remove',
123
+ };
124
+
125
+ function formatOutcome(outcome) {
126
+ const line = `${LABELS[outcome.state]} ${outcome.kind} ${outcome.name}`;
127
+ return outcome.detail ? `${line} (${outcome.detail})` : line;
128
+ }
129
+
130
+ /**
131
+ * Remove one execution's containers, then its snapshot images.
132
+ * @param {?object} store - Execution store
133
+ * @param {string} identifier - UUID or session name
134
+ * @param {object} [deps] - {dryRun, runner}
135
+ * @returns {{success: boolean, output?: string, error?: string}}
136
+ */
137
+ function cleanupExecution(store, identifier, deps = {}) {
138
+ if (!store) {
139
+ return { success: false, error: 'Execution tracking is disabled.' };
140
+ }
141
+ const record = store.get(identifier);
142
+ if (!record) {
143
+ return {
144
+ success: false,
145
+ error: `No execution found with UUID or session name: ${identifier}`,
146
+ };
147
+ }
148
+ if (record.options?.isolated !== 'docker') {
149
+ return {
150
+ success: false,
151
+ error: `--cleanup <execution> only removes Docker containers and images; ${identifier} is not a Docker execution.`,
152
+ };
153
+ }
154
+ const runner = deps.runner || runCommand;
155
+ const dryRun = deps.dryRun === true;
156
+ const plan = planExecutionCleanup(record, runner);
157
+ const outcomes = plan.containers.map((name) =>
158
+ removeObject('container', name, runner, dryRun)
159
+ );
160
+ // Images go only after their containers; Docker refuses a used image.
161
+ for (const image of plan.images) {
162
+ outcomes.push(removeObject('image', image, runner, dryRun));
163
+ }
164
+ const current = outcomes[0];
165
+ if (
166
+ !dryRun &&
167
+ current &&
168
+ ['removed', 'absent'].includes(current.state) &&
169
+ typeof store.markContainerRemoved === 'function'
170
+ ) {
171
+ store.markContainerRemoved(record.uuid, new Date().toISOString());
172
+ }
173
+ const retained = outcomes.filter((outcome) => outcome.state === 'retained');
174
+ const lines = outcomes.length
175
+ ? outcomes.map(formatOutcome)
176
+ : ['No containers or snapshot images recorded for this execution.'];
177
+ const output = lines.join('\n');
178
+ if (retained.length > 0) {
179
+ return {
180
+ success: false,
181
+ output,
182
+ error: `${retained.length} Docker object(s) retained; nothing was forced.`,
183
+ };
184
+ }
185
+ return { success: true, output };
186
+ }
187
+
188
+ module.exports = {
189
+ SNAPSHOT_IMAGE_PATTERN,
190
+ chainImages,
191
+ cleanupExecution,
192
+ planExecutionCleanup,
193
+ };
@@ -17,10 +17,11 @@ const ControlAction = {
17
17
  TERMINATE: 'terminate',
18
18
  };
19
19
 
20
- function runCommand(command, args) {
20
+ function runCommand(command, args, extra = {}) {
21
21
  const options = {
22
22
  encoding: 'utf8',
23
23
  stdio: ['ignore', 'pipe', 'pipe'],
24
+ ...(extra.timeout ? { timeout: extra.timeout } : {}),
24
25
  };
25
26
  const result = spawnSync(
26
27
  command,
@@ -458,6 +458,8 @@ function recoverKilledExecution(params = {}) {
458
458
  record.observedAt = undefined;
459
459
  record.staleDetectedAt = undefined;
460
460
  record.containerStartedAt = undefined;
461
+ record.containerState = undefined;
462
+ record.containerRemovedObservedAt = undefined;
461
463
  const previousData = globalThis.structuredClone(store.get(executionId));
462
464
  const previous = record.constructor.fromObject
463
465
  ? record.constructor.fromObject(previousData)