start-command 0.43.0 → 0.44.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.
@@ -0,0 +1,701 @@
1
+ /**
2
+ * `$ --pause <id>` and `$ --unpause <id>` (issue #263): freeze and thaw a
3
+ * detached Docker execution with the cgroup freezer.
4
+ *
5
+ * `docker pause` stops every process of the container from being scheduled,
6
+ * so CPU is released, while the processes, their memory, the container, its
7
+ * workspace and its log all stay exactly where they are. `docker unpause`
8
+ * continues the very same processes: there is no new container, command,
9
+ * snapshot, execution or recovery attempt. To get the memory back, `--stop`
10
+ * the execution instead.
11
+ *
12
+ * Only Docker can freeze a session; screen and tmux are refused. The pause is
13
+ * recorded on the execution as `options.paused`, `pausedAt`, `pauseCount` and
14
+ * the optional `pausedBy` / `pauseReason`, but Docker stays the source of
15
+ * truth: `--status`, `--list` and the controls reconcile a stale record from
16
+ * `docker inspect` (after a daemon restart, a reboot or a removal), so a stale
17
+ * `paused: true` can never hide a task.
18
+ */
19
+
20
+ const { getDockerCommand } = require('./docker-cleanup');
21
+ const {
22
+ escapeForLinksNotation,
23
+ formatAsNestedLinksNotation,
24
+ } = require('./output-blocks');
25
+ const { collectProcessIds, runCommand } = require('./execution-control');
26
+
27
+ const PauseAction = {
28
+ PAUSE: 'pause',
29
+ UNPAUSE: 'unpause',
30
+ };
31
+
32
+ /** Bound for every docker call made here, so a hung daemon cannot hang us. */
33
+ const DOCKER_PAUSE_TIMEOUT_MS = 30000;
34
+
35
+ /** `docker inspect` template answering both the status and the freezer flag. */
36
+ const PAUSE_STATE_FORMAT = '{{.State.Status}} {{.State.Paused}}';
37
+
38
+ /** Docker states an execution can be reported in (plus `removed`/`unknown`). */
39
+ const DockerState = {
40
+ RUNNING: 'running',
41
+ PAUSED: 'paused',
42
+ EXITED: 'exited',
43
+ CREATED: 'created',
44
+ REMOVED: 'removed',
45
+ UNKNOWN: 'unknown',
46
+ };
47
+
48
+ /** Pause bookkeeping kept in `options`; cleared when the pause ends. */
49
+ const PAUSE_FIELDS = ['pausedAt', 'pausedBy', 'pauseReason'];
50
+
51
+ const NOT_FOUND_PATTERN = /no such (object|container)/i;
52
+
53
+ /** What a pause does to the container's resources, for callers' accounting. */
54
+ const PAUSED_RESOURCES = {
55
+ cpu: 'released',
56
+ memory: 'retained',
57
+ disk: 'retained',
58
+ };
59
+
60
+ function failureDetail(result) {
61
+ return (
62
+ String(result?.stderr || '').trim() ||
63
+ result?.error ||
64
+ `exit code ${result?.status}`
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Parse `docker inspect -f '{{.State.Status}} {{.State.Paused}}'` output.
70
+ * @param {string} stdout - Inspect output
71
+ * @returns {{state: string, paused: boolean}} Docker state
72
+ */
73
+ function parsePauseState(stdout) {
74
+ const [statusRaw, pausedRaw] = String(stdout || '')
75
+ .trim()
76
+ .split(/\s+/);
77
+ const state = String(statusRaw || '').toLowerCase() || DockerState.UNKNOWN;
78
+ const paused = pausedRaw === 'true' || state === DockerState.PAUSED;
79
+ return { state: paused ? DockerState.PAUSED : state, paused };
80
+ }
81
+
82
+ /**
83
+ * Ask Docker for the real state of a container.
84
+ * @param {string} containerName - Container name or id
85
+ * @param {Function} [runner] - Command runner (injectable for tests)
86
+ * @returns {{state: string, paused: boolean, detail?: string}} `removed` when
87
+ * Docker answers "no such container", `unknown` when it cannot be asked
88
+ */
89
+ function inspectPauseState(containerName, runner = runCommand) {
90
+ const result = runner(
91
+ getDockerCommand(),
92
+ ['inspect', '-f', PAUSE_STATE_FORMAT, containerName],
93
+ { timeout: DOCKER_PAUSE_TIMEOUT_MS }
94
+ );
95
+ if (result.success) {
96
+ return parsePauseState(result.stdout);
97
+ }
98
+ const detail = failureDetail(result);
99
+ if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
100
+ return { state: DockerState.REMOVED, paused: false };
101
+ }
102
+ return { state: DockerState.UNKNOWN, paused: false, detail };
103
+ }
104
+
105
+ /**
106
+ * Whether a record is a detached Docker execution that Docker confirms is
107
+ * paused right now. A stale `options.paused` alone never counts.
108
+ * @param {object} record - Execution record
109
+ * @param {Function} [runner] - Command runner
110
+ * @returns {boolean} True only when `docker inspect` reports it paused
111
+ */
112
+ function isConfirmedPaused(record, runner = runCommand) {
113
+ const opts = (record && record.options) || {};
114
+ if (
115
+ opts.isolated !== 'docker' ||
116
+ opts.isolationMode !== 'detached' ||
117
+ !opts.sessionName ||
118
+ record.containerState === DockerState.REMOVED
119
+ ) {
120
+ return false;
121
+ }
122
+ return inspectPauseState(opts.sessionName, runner).paused;
123
+ }
124
+
125
+ /**
126
+ * Options of a record whose pause just ended.
127
+ * @param {object} options - Current options
128
+ * @param {string} endedBy - unpause, stop, terminate or reconcile
129
+ * @param {string} [at] - When the pause ended
130
+ * @returns {object} New options (the input is not mutated)
131
+ */
132
+ function clearedPauseOptions(options, endedBy, at = new Date().toISOString()) {
133
+ const next = { ...(options || {}) };
134
+ for (const field of PAUSE_FIELDS) {
135
+ delete next[field];
136
+ }
137
+ next.paused = false;
138
+ next.unpausedAt = at;
139
+ next.pauseEndedBy = endedBy;
140
+ return next;
141
+ }
142
+
143
+ /**
144
+ * Options of a record that was just paused.
145
+ * @param {object} options - Current options
146
+ * @param {{reason?: ?string, by?: ?string, at?: string}} details - Who and why
147
+ * @returns {object} New options (the input is not mutated)
148
+ */
149
+ function pausedOptions(options, details = {}) {
150
+ const next = { ...(options || {}) };
151
+ delete next.unpausedAt;
152
+ delete next.pauseEndedBy;
153
+ delete next.pausedBy;
154
+ delete next.pauseReason;
155
+ next.paused = true;
156
+ next.pausedAt = details.at || new Date().toISOString();
157
+ next.pauseCount = (Number(next.pauseCount) || 0) + 1;
158
+ if (details.by) {
159
+ next.pausedBy = details.by;
160
+ }
161
+ if (details.reason) {
162
+ next.pauseReason = details.reason;
163
+ }
164
+ return next;
165
+ }
166
+
167
+ /**
168
+ * Re-read the record and save new options on it, best effort for
169
+ * reconciliation and strict for the pause/unpause transitions themselves.
170
+ * @returns {object} The saved record
171
+ */
172
+ function saveOptions(store, record, update, strict = true) {
173
+ let current = record;
174
+ try {
175
+ current = store.get(record.uuid) || record;
176
+ } catch {
177
+ // Fall back to the record we already have.
178
+ }
179
+ current.options = update(current.options || {});
180
+ try {
181
+ store.save(current);
182
+ } catch (error) {
183
+ if (strict) {
184
+ throw error;
185
+ }
186
+ }
187
+ return current;
188
+ }
189
+
190
+ /**
191
+ * Forget a pause that Docker no longer confirms (issue #263): a container that
192
+ * was stopped, removed, restarted by the daemon or thawed by hand.
193
+ * @returns {object} The (possibly updated) record
194
+ */
195
+ function reconcileStalePause(store, record, endedBy = 'reconcile') {
196
+ if (!store || !record.options || record.options.paused !== true) {
197
+ return record;
198
+ }
199
+ return saveOptions(
200
+ store,
201
+ record,
202
+ (options) => clearedPauseOptions(options, endedBy),
203
+ false
204
+ );
205
+ }
206
+
207
+ /**
208
+ * End the recorded pause of an execution that `--stop` / `--terminate` just
209
+ * ended (issue #263). No-op when it was not paused.
210
+ * @param {object} store - Execution store
211
+ * @param {object} record - Execution record
212
+ * @param {string} action - stop or terminate
213
+ */
214
+ function clearPauseAfterControl(store, record, action) {
215
+ if (record?.options?.paused === true) {
216
+ reconcileStalePause(store, record, action);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Validate that an execution can be paused/unpaused at all.
222
+ * @returns {{record?: object, error?: string}} Record, or the refusal
223
+ */
224
+ function resolvePauseTarget(store, identifier, action) {
225
+ if (!store) {
226
+ return { error: 'Execution tracking is disabled.' };
227
+ }
228
+ const record = store.get(identifier);
229
+ if (!record) {
230
+ return {
231
+ error: `No execution found with UUID or session name: ${identifier}`,
232
+ };
233
+ }
234
+ const opts = record.options || {};
235
+ if (opts.isolated !== 'docker') {
236
+ return {
237
+ error:
238
+ `--${action} only supports Docker isolation: ${identifier} uses ${opts.isolated || 'no isolation'}. ` +
239
+ 'screen and tmux sessions cannot be frozen; use --stop or --terminate instead.',
240
+ };
241
+ }
242
+ if (!opts.sessionName) {
243
+ return {
244
+ error: 'Execution record does not contain an isolation session name.',
245
+ };
246
+ }
247
+ if (opts.isolationMode !== 'detached') {
248
+ return {
249
+ error: `Only detached Docker executions can be ${action}d.`,
250
+ };
251
+ }
252
+ return { record };
253
+ }
254
+
255
+ function notPausableError(action, identifier, record, dockerState) {
256
+ const name = record.options.sessionName;
257
+ const what =
258
+ dockerState.state === DockerState.REMOVED
259
+ ? 'no longer exists (removed)'
260
+ : `is ${dockerState.state}, not ${action === PauseAction.PAUSE ? 'running' : 'paused'}`;
261
+ return (
262
+ `Cannot ${action} ${identifier}: container "${name}" ${what}. ` +
263
+ `Use \`$ --status ${record.uuid}\` to inspect it, or \`$ --resume ${record.uuid}\` to continue it.`
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Report of one pause/unpause, in every output format.
269
+ * @param {object} result - {action, identifier, status, record, method,
270
+ * dockerState, processIds, message}
271
+ * @returns {object} Plain report object
272
+ */
273
+ function buildPauseReport(result) {
274
+ const opts = result.record.options || {};
275
+ const report = {
276
+ action: result.action,
277
+ identifier: result.identifier,
278
+ uuid: result.record.uuid,
279
+ status: result.status,
280
+ backend: opts.isolated,
281
+ sessionName: opts.sessionName,
282
+ method: result.method,
283
+ dockerState: result.dockerState,
284
+ paused: result.dockerState === DockerState.PAUSED,
285
+ };
286
+ if (report.paused) {
287
+ report.pausedAt = opts.pausedAt || null;
288
+ report.pauseCount = Number(opts.pauseCount) || 0;
289
+ if (opts.pausedBy) {
290
+ report.pausedBy = opts.pausedBy;
291
+ }
292
+ if (opts.pauseReason) {
293
+ report.pauseReason = opts.pauseReason;
294
+ }
295
+ report.resources = { ...PAUSED_RESOURCES };
296
+ } else if (opts.unpausedAt) {
297
+ report.unpausedAt = opts.unpausedAt;
298
+ }
299
+ if (result.processIds) {
300
+ report.processIds = result.processIds;
301
+ }
302
+ report.message = result.message;
303
+ return report;
304
+ }
305
+
306
+ function formatPauseReportAsLinksNotation(report) {
307
+ const lines = ['executionControl'];
308
+ for (const [key, value] of Object.entries(report)) {
309
+ if (value !== null && typeof value === 'object') {
310
+ lines.push(` ${key}`);
311
+ lines.push(formatAsNestedLinksNotation(value, 2, 2));
312
+ } else {
313
+ lines.push(` ${key} ${escapeForLinksNotation(value)}`);
314
+ }
315
+ }
316
+ return lines.join('\n');
317
+ }
318
+
319
+ function formatPauseReportAsText(report) {
320
+ const lines = [
321
+ report.message,
322
+ `UUID: ${report.uuid}`,
323
+ `Container: ${report.sessionName}`,
324
+ `Docker State: ${report.dockerState}`,
325
+ `Paused: ${report.paused}`,
326
+ ];
327
+ if (report.paused) {
328
+ lines.push(`Paused At: ${report.pausedAt || 'N/A'}`);
329
+ lines.push(`Pause Count: ${report.pauseCount}`);
330
+ lines.push(
331
+ `Resources: CPU ${report.resources.cpu}, memory ${report.resources.memory}, disk ${report.resources.disk}`
332
+ );
333
+ }
334
+ if (report.processIds?.containerPid) {
335
+ lines.push(`Container PID: ${report.processIds.containerPid}`);
336
+ }
337
+ return lines.join('\n');
338
+ }
339
+
340
+ /**
341
+ * Render a pause/unpause report.
342
+ * @param {object} report - From `buildPauseReport`
343
+ * @param {?string} outputFormat - links-notation (default), json or text
344
+ * @returns {string} Output
345
+ */
346
+ function formatPauseReport(report, outputFormat) {
347
+ if (outputFormat === 'json') {
348
+ return JSON.stringify(report, null, 2);
349
+ }
350
+ if (outputFormat === 'text') {
351
+ return formatPauseReportAsText(report);
352
+ }
353
+ return formatPauseReportAsLinksNotation(report);
354
+ }
355
+
356
+ function success(fields, deps) {
357
+ const record = fields.record;
358
+ // Every docker call of --pause/--unpause is bounded, the PID lookup too.
359
+ const processIds = collectProcessIds(record, (command, args, extra) =>
360
+ deps.runner(command, args, { timeout: DOCKER_PAUSE_TIMEOUT_MS, ...extra })
361
+ );
362
+ return {
363
+ success: true,
364
+ output: formatPauseReport(
365
+ buildPauseReport({ ...fields, processIds }),
366
+ deps.outputFormat
367
+ ),
368
+ };
369
+ }
370
+
371
+ function runDocker(runner, args) {
372
+ return runner(getDockerCommand(), args, {
373
+ timeout: DOCKER_PAUSE_TIMEOUT_MS,
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Handle a container Docker no longer has: record the removal and drop a
379
+ * stale pause, then fail with a recovery path.
380
+ */
381
+ function failRemoved(store, action, identifier, record, dockerState) {
382
+ if (typeof store.markContainerRemoved === 'function') {
383
+ try {
384
+ store.markContainerRemoved(record.uuid, new Date().toISOString());
385
+ } catch {
386
+ // Reported either way; `--status` observes the removal again.
387
+ }
388
+ }
389
+ reconcileStalePause(store, record);
390
+ return {
391
+ success: false,
392
+ error: notPausableError(action, identifier, record, dockerState),
393
+ };
394
+ }
395
+
396
+ /**
397
+ * `$ --pause <id>`: freeze a running detached Docker execution.
398
+ * @param {?object} store - Execution store
399
+ * @param {string} identifier - UUID or session name
400
+ * @param {object} [deps] - {runner, outputFormat, reason, by}
401
+ * @returns {{success: boolean, output?: string, error?: string}}
402
+ */
403
+ function pauseExecution(store, identifier, deps = {}) {
404
+ const action = PauseAction.PAUSE;
405
+ const target = resolvePauseTarget(store, identifier, action);
406
+ if (target.error) {
407
+ return { success: false, error: target.error };
408
+ }
409
+ let record = target.record;
410
+ const runner = deps.runner || runCommand;
411
+ const context = { runner, outputFormat: deps.outputFormat };
412
+ const name = record.options.sessionName;
413
+ const dockerState = inspectPauseState(name, runner);
414
+
415
+ if (dockerState.state === DockerState.REMOVED) {
416
+ return failRemoved(store, action, identifier, record, dockerState);
417
+ }
418
+ if (dockerState.state === DockerState.UNKNOWN) {
419
+ return {
420
+ success: false,
421
+ error: `Cannot ${action} ${identifier}: docker inspect ${name} failed: ${dockerState.detail}`,
422
+ };
423
+ }
424
+ if (dockerState.paused) {
425
+ if (record.options.paused !== true) {
426
+ // Paused outside start-command: adopt it so status and cleanup know.
427
+ record = saveOptions(store, record, (o) =>
428
+ pausedOptions(o, { by: deps.by, reason: deps.reason })
429
+ );
430
+ }
431
+ return success(
432
+ {
433
+ action,
434
+ identifier,
435
+ record,
436
+ status: 'already-paused',
437
+ method: 'NONE',
438
+ dockerState: DockerState.PAUSED,
439
+ message: `Container ${name} is already paused; nothing changed.`,
440
+ },
441
+ context
442
+ );
443
+ }
444
+ if (dockerState.state !== DockerState.RUNNING) {
445
+ reconcileStalePause(store, record);
446
+ return {
447
+ success: false,
448
+ error: notPausableError(action, identifier, record, dockerState),
449
+ };
450
+ }
451
+
452
+ const result = runDocker(runner, ['pause', name]);
453
+ if (!result.success) {
454
+ return {
455
+ success: false,
456
+ error: `Failed to pause docker container "${name}": ${failureDetail(result)}`,
457
+ };
458
+ }
459
+ record = saveOptions(store, record, (o) =>
460
+ pausedOptions(o, { by: deps.by, reason: deps.reason })
461
+ );
462
+ return success(
463
+ {
464
+ action,
465
+ identifier,
466
+ record,
467
+ status: 'paused',
468
+ method: 'DOCKER_PAUSE',
469
+ dockerState: DockerState.PAUSED,
470
+ message:
471
+ `Paused detached docker container: ${name}. CPU is released; memory stays allocated ` +
472
+ `(use \`$ --stop ${record.uuid}\` to free it). \`$ --unpause ${record.uuid}\` continues the same process.`,
473
+ },
474
+ context
475
+ );
476
+ }
477
+
478
+ /**
479
+ * `$ --unpause <id>`: thaw a paused detached Docker execution.
480
+ * @param {?object} store - Execution store
481
+ * @param {string} identifier - UUID or session name
482
+ * @param {object} [deps] - {runner, outputFormat}
483
+ * @returns {{success: boolean, output?: string, error?: string}}
484
+ */
485
+ function unpauseExecution(store, identifier, deps = {}) {
486
+ const action = PauseAction.UNPAUSE;
487
+ const target = resolvePauseTarget(store, identifier, action);
488
+ if (target.error) {
489
+ return { success: false, error: target.error };
490
+ }
491
+ let record = target.record;
492
+ const runner = deps.runner || runCommand;
493
+ const context = { runner, outputFormat: deps.outputFormat };
494
+ const name = record.options.sessionName;
495
+ const dockerState = inspectPauseState(name, runner);
496
+
497
+ if (dockerState.state === DockerState.REMOVED) {
498
+ return failRemoved(store, action, identifier, record, dockerState);
499
+ }
500
+ if (dockerState.state === DockerState.UNKNOWN) {
501
+ return {
502
+ success: false,
503
+ error: `Cannot ${action} ${identifier}: docker inspect ${name} failed: ${dockerState.detail}`,
504
+ };
505
+ }
506
+ if (dockerState.state === DockerState.RUNNING) {
507
+ // Thawed by hand or by a daemon restart: report the truth.
508
+ record = reconcileStalePause(store, record);
509
+ return success(
510
+ {
511
+ action,
512
+ identifier,
513
+ record,
514
+ status: 'not-paused',
515
+ method: 'NONE',
516
+ dockerState: DockerState.RUNNING,
517
+ message: `Container ${name} is running and not paused; nothing changed.`,
518
+ },
519
+ context
520
+ );
521
+ }
522
+ if (!dockerState.paused) {
523
+ reconcileStalePause(store, record);
524
+ return {
525
+ success: false,
526
+ error: notPausableError(action, identifier, record, dockerState),
527
+ };
528
+ }
529
+
530
+ const result = runDocker(runner, ['unpause', name]);
531
+ if (!result.success) {
532
+ return {
533
+ success: false,
534
+ error: `Failed to unpause docker container "${name}": ${failureDetail(result)}`,
535
+ };
536
+ }
537
+ record = saveOptions(store, record, (o) =>
538
+ clearedPauseOptions(o, PauseAction.UNPAUSE)
539
+ );
540
+ return success(
541
+ {
542
+ action,
543
+ identifier,
544
+ record,
545
+ status: 'unpaused',
546
+ method: 'DOCKER_UNPAUSE',
547
+ dockerState: DockerState.RUNNING,
548
+ message: `Unpaused detached docker container: ${name}. The same process continues.`,
549
+ },
550
+ context
551
+ );
552
+ }
553
+
554
+ /**
555
+ * The pause fields `--status` and `--list` report for a detached Docker
556
+ * execution, from what the status probe saw.
557
+ * @param {object} record - Execution record (after status reconciliation)
558
+ * @param {object} probe - Status probe; `dockerState` is the inspected state
559
+ * and `sessionNotFound` says Docker answered "no such container"
560
+ * @returns {?{dockerState: string, paused: boolean, pausedAt?: string}} Fields,
561
+ * or null for records that are not detached Docker executions
562
+ */
563
+ function observedPauseFields(record, probe) {
564
+ const opts = (record && record.options) || {};
565
+ if (opts.isolated !== 'docker' || opts.isolationMode !== 'detached') {
566
+ return null;
567
+ }
568
+ const inspected = probe && probe.dockerState;
569
+ let dockerState;
570
+ if (record.containerState === DockerState.REMOVED || probe?.sessionNotFound) {
571
+ dockerState = DockerState.REMOVED;
572
+ } else if (inspected && inspected.status) {
573
+ dockerState = inspected.paused ? DockerState.PAUSED : inspected.status;
574
+ } else if (inspected) {
575
+ dockerState = inspected.running ? DockerState.RUNNING : DockerState.EXITED;
576
+ } else {
577
+ dockerState = DockerState.UNKNOWN;
578
+ }
579
+ // Unknown liveness (docker unreachable) keeps the recorded pause.
580
+ const paused =
581
+ dockerState === DockerState.UNKNOWN
582
+ ? opts.paused === true
583
+ : dockerState === DockerState.PAUSED;
584
+ const fields = { dockerState, paused };
585
+ if (paused) {
586
+ fields.pausedAt = opts.pausedAt || null;
587
+ }
588
+ return fields;
589
+ }
590
+
591
+ /**
592
+ * Attach `dockerState`, `paused` and `pausedAt` to a status record, and make
593
+ * its recorded pause agree with Docker (issue #263). The input is not mutated.
594
+ * @param {object} record - Enriched execution record
595
+ * @param {object} probe - Status probe (see `observedPauseFields`)
596
+ * @returns {object} Record-like object whose `toObject()` carries the fields
597
+ */
598
+ function attachPauseState(record, probe) {
599
+ const fields = observedPauseFields(record, probe);
600
+ if (!fields) {
601
+ return record;
602
+ }
603
+ const wrapped = Object.assign(
604
+ Object.create(Object.getPrototypeOf(record)),
605
+ record
606
+ );
607
+ const opts = record.options || {};
608
+ if (fields.dockerState !== DockerState.UNKNOWN) {
609
+ if (fields.paused && opts.paused !== true) {
610
+ wrapped.options = pausedOptions(opts);
611
+ fields.pausedAt = wrapped.options.pausedAt;
612
+ wrapped.pauseReconciled = true;
613
+ } else if (!fields.paused && opts.paused === true) {
614
+ wrapped.options = clearedPauseOptions(opts, 'reconcile');
615
+ wrapped.pauseReconciled = true;
616
+ }
617
+ }
618
+ Object.assign(wrapped, fields);
619
+ // `this`, not `wrapped`: later enrichment copies the record (exit reason,
620
+ // attempt history) and those copies must still serialize their own fields.
621
+ const baseToObject = record.toObject;
622
+ wrapped.toObject = function () {
623
+ const ordered = {};
624
+ const base =
625
+ typeof baseToObject === 'function'
626
+ ? baseToObject.call(this)
627
+ : { ...this };
628
+ for (const [key, value] of Object.entries(base)) {
629
+ ordered[key] = value;
630
+ if (key === 'status') {
631
+ Object.assign(ordered, fields);
632
+ }
633
+ }
634
+ return Object.assign(ordered, fields);
635
+ };
636
+ return wrapped;
637
+ }
638
+
639
+ /**
640
+ * Persist the pause reconciliation `--status` / `--list` observed, best
641
+ * effort: a read-only query must never fail because the store is busy.
642
+ * @param {?object} store - Execution store
643
+ * @param {object} reported - Record returned by `attachPauseState`
644
+ */
645
+ function persistPauseReconciliation(store, reported) {
646
+ if (!store || !reported || reported.pauseReconciled !== true) {
647
+ return;
648
+ }
649
+ const target = reported.options || {};
650
+ saveOptions(
651
+ store,
652
+ reported,
653
+ (options) =>
654
+ target.paused
655
+ ? pausedOptions(options, { at: target.pausedAt })
656
+ : clearedPauseOptions(options, 'reconcile', target.unpausedAt),
657
+ false
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Human-readable pause lines for `--status --output-format text`.
663
+ * @param {object} obj - Serialized record
664
+ * @returns {string[]} Lines (empty for non-Docker records)
665
+ */
666
+ function pauseTextLines(obj) {
667
+ if (obj.dockerState === undefined) {
668
+ return [];
669
+ }
670
+ const lines = [
671
+ `Docker State: ${obj.dockerState}`,
672
+ `Paused: ${obj.paused}`,
673
+ ];
674
+ if (obj.paused) {
675
+ lines.push(`Paused At: ${obj.pausedAt || 'N/A'}`);
676
+ }
677
+ return lines;
678
+ }
679
+
680
+ module.exports = {
681
+ DOCKER_PAUSE_TIMEOUT_MS,
682
+ DockerState,
683
+ PAUSED_RESOURCES,
684
+ PAUSE_STATE_FORMAT,
685
+ PauseAction,
686
+ attachPauseState,
687
+ buildPauseReport,
688
+ clearPauseAfterControl,
689
+ clearedPauseOptions,
690
+ formatPauseReport,
691
+ inspectPauseState,
692
+ isConfirmedPaused,
693
+ observedPauseFields,
694
+ parsePauseState,
695
+ pauseExecution,
696
+ pauseTextLines,
697
+ pausedOptions,
698
+ persistPauseReconciliation,
699
+ reconcileStalePause,
700
+ unpauseExecution,
701
+ };
@@ -239,6 +239,12 @@ function recoverKilledExecution(params = {}) {
239
239
  log('\n[Recovery] Not resuming: the session was stopped on request.\n');
240
240
  return { recovered: false, reason: 'stop-requested' };
241
241
  }
242
+ if (opts.paused === true) {
243
+ // A paused container only exits when it is stopped or killed on purpose;
244
+ // a pause is never a crash to recover from (issue #263).
245
+ log('\n[Recovery] Not resuming: the session was paused when it exited.\n');
246
+ return { recovered: false, reason: 'paused' };
247
+ }
242
248
  const used = Number(opts.recoveryAttempts) || 0;
243
249
  if (used >= maxAttempts) {
244
250
  log(