start-command 0.37.8 → 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.
@@ -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)
@@ -54,6 +54,7 @@ const {
54
54
  preflightSnapshot,
55
55
  SNAPSHOT_IMAGE_LABEL,
56
56
  } = require('./docker-snapshot-safety');
57
+ const snapshotResume = require('./docker-snapshot-resume');
57
58
  const {
58
59
  ResumeAllAction,
59
60
  resumeAllExecutions,
@@ -441,8 +442,10 @@ function activeSessionName(plan) {
441
442
  * Resume a tracked execution by UUID or session name.
442
443
  * @param {?object} store - Execution store
443
444
  * @param {string} identifier - UUID or session name
444
- * @param {object} deps - {command, outputFormat, probe, runner, startWatcher, runIsolated}
445
- * @returns {Promise<{success: boolean, output?: string, error?: string}>}
445
+ * @param {object} deps - {command, outputFormat, probe, runner, runAsync,
446
+ * startWatcher, runIsolated, removeOriginal, removeOriginalAfter,
447
+ * snapshotOptions, notify}
448
+ * @returns {Promise<{success: boolean, output?: string, error?: string, exitCode?: number}>}
446
449
  */
447
450
  async function resumeExecution(store, identifier, deps = {}) {
448
451
  if (!store) {
@@ -605,16 +608,34 @@ async function resumeExecution(store, identifier, deps = {}) {
605
608
  }
606
609
  containerId = launchResult.containerId || null;
607
610
  } else {
611
+ const notify = deps.notify || ((line) => process.stderr.write(`${line}\n`));
612
+ const snapshotOptions = { notify, ...deps.snapshotOptions };
613
+ const progress = {
614
+ sessionName: plan.sessionName,
615
+ logPath: record.logPath,
616
+ notify,
617
+ };
608
618
  let snapshotLock;
609
619
  let snapshotCreated = false;
610
620
  let derivedCreated = false;
611
621
  try {
612
622
  if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
613
- snapshotLock = acquireSnapshotLock(deps.snapshotOptions);
623
+ snapshotLock = acquireSnapshotLock({
624
+ ...snapshotOptions,
625
+ holder: plan.sessionName,
626
+ });
627
+ if (snapshotOptions.prepare) {
628
+ snapshotResume.runSnapshotPrepare(
629
+ plan.sessionName,
630
+ snapshotOptions.prepare,
631
+ runner,
632
+ progress
633
+ );
634
+ }
614
635
  const preflight = preflightSnapshot(
615
636
  plan.sessionName,
616
637
  runner,
617
- deps.snapshotOptions
638
+ snapshotOptions
618
639
  );
619
640
  plan.message += `\n${preflight.message}`;
620
641
  if (record.logPath) {
@@ -639,7 +660,18 @@ async function resumeExecution(store, identifier, deps = {}) {
639
660
  for (const step of plan.steps) {
640
661
  let result;
641
662
  try {
642
- result = runner(step.command, step.args);
663
+ result =
664
+ step.args[0] === 'commit'
665
+ ? await snapshotResume.commitWithProgress(step, {
666
+ ...progress,
667
+ runAsync:
668
+ deps.runAsync ||
669
+ (deps.runner
670
+ ? (command, args) =>
671
+ Promise.resolve().then(() => runner(command, args))
672
+ : snapshotResume.runCommandAsync),
673
+ })
674
+ : runner(step.command, step.args);
643
675
  } catch (error) {
644
676
  result = { success: false, error: error.message };
645
677
  }
@@ -661,7 +693,11 @@ async function resumeExecution(store, identifier, deps = {}) {
661
693
  containerId = (result.stdout || '').trim() || containerId;
662
694
  }
663
695
  }
664
- if (plan.mode === ResumeMode.DOCKER_SNAPSHOT && deps.removeOriginal) {
696
+ if (
697
+ plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
698
+ deps.removeOriginal &&
699
+ !(deps.removeOriginalAfter > 0)
700
+ ) {
665
701
  const running = runner(getDockerCommand(), [
666
702
  'inspect',
667
703
  '-f',
@@ -704,6 +740,7 @@ async function resumeExecution(store, identifier, deps = {}) {
704
740
  return {
705
741
  success: false,
706
742
  error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
743
+ ...(error.exitCode ? { exitCode: error.exitCode } : {}),
707
744
  };
708
745
  } finally {
709
746
  snapshotLock?.release();
@@ -793,6 +830,23 @@ async function resumeExecution(store, identifier, deps = {}) {
793
830
  };
794
831
  }
795
832
 
833
+ if (
834
+ plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
835
+ deps.removeOriginal &&
836
+ deps.removeOriginalAfter > 0
837
+ ) {
838
+ plan.message += `\n${await snapshotResume.removeOriginalAfter(
839
+ plan,
840
+ deps.removeOriginalAfter,
841
+ runner,
842
+ {
843
+ logPath: record.logPath,
844
+ notify: deps.notify || ((line) => process.stderr.write(`${line}\n`)),
845
+ sleep: deps.sleep,
846
+ }
847
+ )}`;
848
+ }
849
+
796
850
  return {
797
851
  success: true,
798
852
  output: formatResumeResult(
@@ -101,6 +101,14 @@ class ExecutionRecord {
101
101
  options.containerStartedAt !== undefined
102
102
  ? options.containerStartedAt
103
103
  : undefined;
104
+ // `removed` once the container is known to be gone (issue #225), with
105
+ // when that was observed; `options.containerId` is kept as history.
106
+ this.containerState =
107
+ options.containerState !== undefined ? options.containerState : undefined;
108
+ this.containerRemovedObservedAt =
109
+ options.containerRemovedObservedAt !== undefined
110
+ ? options.containerRemovedObservedAt
111
+ : undefined;
104
112
  this.oomKilled =
105
113
  options.oomKilled !== undefined ? options.oomKilled : undefined;
106
114
  // Query-time hint explaining an opaque exit code (issue #162). Derived from
@@ -179,6 +187,15 @@ class ExecutionRecord {
179
187
  ) {
180
188
  obj.containerStartedAt = this.containerStartedAt;
181
189
  }
190
+ if (this.containerState !== undefined && this.containerState !== null) {
191
+ obj.containerState = this.containerState;
192
+ }
193
+ if (
194
+ this.containerRemovedObservedAt !== undefined &&
195
+ this.containerRemovedObservedAt !== null
196
+ ) {
197
+ obj.containerRemovedObservedAt = this.containerRemovedObservedAt;
198
+ }
182
199
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
183
200
  obj.oomKilled = this.oomKilled;
184
201
  }
@@ -489,6 +506,41 @@ class ExecutionStore {
489
506
  }
490
507
  }
491
508
 
509
+ /**
510
+ * Record that a finished execution's container no longer exists
511
+ * (issue #225). Applied under the database lock to the stored record, and
512
+ * only while it is still finished, so a concurrent resume is never undone.
513
+ * @param {string} uuid - Execution UUID
514
+ * @param {string} observedAt - When the removal was observed
515
+ * @returns {ExecutionRecord|null} The updated record, or null
516
+ */
517
+ markContainerRemoved(uuid, observedAt) {
518
+ const lock = new LockManager(this.lockFilePath);
519
+ if (!lock.acquire()) {
520
+ throw new Error('Failed to acquire lock for container state');
521
+ }
522
+ try {
523
+ const records = this.readLinoRecords();
524
+ const current = records.find((record) => record.uuid === uuid);
525
+ if (
526
+ !current ||
527
+ current.status !== ExecutionStatus.EXECUTED ||
528
+ current.containerState === 'removed'
529
+ ) {
530
+ return null;
531
+ }
532
+ current.containerState = 'removed';
533
+ current.containerRemovedObservedAt = observedAt;
534
+ this.writeLinoRecords(records);
535
+ if (this.useLinks) {
536
+ this.writeLinksRecord(current);
537
+ }
538
+ return current;
539
+ } finally {
540
+ lock.release();
541
+ }
542
+ }
543
+
492
544
  /**
493
545
  * Get an execution record by UUID or session name
494
546
  * First tries exact UUID match, then the current session name, then any
@@ -813,8 +813,10 @@ function runInDocker(command, options = {}) {
813
813
  !containerExistedBeforeLaunch &&
814
814
  readDockerContainerStatus(containerName) === 'created';
815
815
 
816
+ let containerRemoved = false;
816
817
  if (launchFailed) {
817
818
  if (removeDockerContainer(containerName, options.logPath)) {
819
+ containerRemoved = true;
818
820
  message += `\nContainer removed after launch failure.`;
819
821
  } else {
820
822
  message += `\nWarning: failed to remove container after launch failure.`;
@@ -823,6 +825,7 @@ function runInDocker(command, options = {}) {
823
825
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
824
826
  ) {
825
827
  if (removeDockerContainer(containerName, options.logPath)) {
828
+ containerRemoved = true;
826
829
  message += `\nContainer removed after completion.${postMortem(true)}`;
827
830
  } else {
828
831
  message += `\nWarning: failed to remove container automatically.`;
@@ -848,6 +851,7 @@ function runInDocker(command, options = {}) {
848
851
  diagnostics: diagnosticFacts,
849
852
  message,
850
853
  exitCode,
854
+ containerRemoved,
851
855
  });
852
856
  });
853
857