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.
@@ -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
 
@@ -13,6 +13,8 @@ const { ControlAction, controlExecution } = require('./execution-control');
13
13
  const { attachExecution } = require('./execution-attach');
14
14
  const { resumeAllExecutions, resumeExecution } = require('./execution-resume');
15
15
  const { uploadExecutionLog } = require('./log-uploader');
16
+ const { snapshotResumeSettings } = require('./snapshot-resume-options');
17
+ const { cleanupExecution } = require('./execution-cleanup');
16
18
 
17
19
  /**
18
20
  * Print a handler result, returning the process exit code.
@@ -68,6 +70,8 @@ async function handleResumeQuery(
68
70
  outputFormat,
69
71
  resourceOptions,
70
72
  removeOriginal: resourceOptions.removeOriginal === true,
73
+ removeOriginalAfter: resourceOptions.removeOriginalAfter ?? 0,
74
+ snapshotOptions: snapshotResumeSettings(resourceOptions),
71
75
  })
72
76
  );
73
77
  }
@@ -78,12 +82,21 @@ function handleResumeAllQuery(store, outputFormat) {
78
82
 
79
83
  /**
80
84
  * Handle --cleanup / --cleanup-dry-run.
81
- * Cleans up stale "executing" records (processes that crashed or were killed).
85
+ * Cleans up stale "executing" records (processes that crashed or were killed),
86
+ * or with a target the Docker containers and snapshot images of one execution.
82
87
  * @param {?object} store - Execution store
83
88
  * @param {boolean} dryRun - If true, just report what would be cleaned
89
+ * @param {?string} [target] - UUID or session name of one execution
84
90
  * @returns {number} Exit code
85
91
  */
86
- function handleCleanup(store, dryRun) {
92
+ function handleCleanup(store, dryRun, target = null) {
93
+ if (hasValue(target)) {
94
+ const result = cleanupExecution(store, target, { dryRun });
95
+ if (!result.success && result.output) {
96
+ console.log(result.output);
97
+ }
98
+ return reportResult(result);
99
+ }
87
100
  if (!store) {
88
101
  console.error('Error: Execution tracking is disabled.');
89
102
  return 1;
@@ -193,7 +206,7 @@ async function dispatchQueryCommand(options, context = {}) {
193
206
  return handleResumeAllQuery(store, outputFormat);
194
207
  }
195
208
  if (options.cleanup) {
196
- return handleCleanup(store, options.cleanupDryRun);
209
+ return handleCleanup(store, options.cleanupDryRun, options.cleanupTarget);
197
210
  }
198
211
 
199
212
  return null;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Options for the guarded Docker snapshot resume (issue #224):
3
+ * --snapshot-wait <seconds> wait for the host snapshot lock
4
+ * --snapshot-disk-wait <seconds> wait for free disk instead of refusing
5
+ * --remove-original-after <seconds> remove the original only if the
6
+ * replacement still runs after the delay
7
+ * --snapshot-size-hint <bytes> skip the `docker inspect --size` walk
8
+ * --snapshot-prepare <command> `docker exec` a command in the original
9
+ * before it is committed
10
+ *
11
+ * They only apply to `--resume`. The two waits also read
12
+ * START_SNAPSHOT_LOCK_WAIT and START_SNAPSHOT_DISK_WAIT when the flags are
13
+ * absent, so a supervisor can set them once for every resume it runs.
14
+ */
15
+
16
+ const FIELDS = {
17
+ '--snapshot-wait': 'snapshotWait',
18
+ '--snapshot-disk-wait': 'snapshotDiskWait',
19
+ '--remove-original-after': 'removeOriginalAfter',
20
+ '--snapshot-size-hint': 'snapshotSizeHint',
21
+ '--snapshot-prepare': 'snapshotPrepare',
22
+ };
23
+
24
+ const ENV_DEFAULTS = {
25
+ snapshotWait: ['START_SNAPSHOT_LOCK_WAIT', '--snapshot-wait'],
26
+ snapshotDiskWait: ['START_SNAPSHOT_DISK_WAIT', '--snapshot-disk-wait'],
27
+ };
28
+
29
+ const UNITS = {
30
+ '': 1,
31
+ b: 1,
32
+ k: 1024,
33
+ m: 1024 ** 2,
34
+ g: 1024 ** 3,
35
+ t: 1024 ** 4,
36
+ };
37
+
38
+ function createSnapshotOptionDefaults() {
39
+ return {
40
+ snapshotWait: null,
41
+ snapshotDiskWait: null,
42
+ removeOriginalAfter: null,
43
+ snapshotSizeHint: null,
44
+ snapshotPrepare: null,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Parse a non-negative number of seconds; fractions are allowed.
50
+ * @param {string} value - Raw value
51
+ * @param {string} name - Option or variable name for the error message
52
+ * @returns {number} Seconds
53
+ */
54
+ function parseSeconds(value, name) {
55
+ const text = String(value).trim();
56
+ const seconds = Number(text);
57
+ if (!/^\d+(?:\.\d+)?$/.test(text) || !Number.isFinite(seconds)) {
58
+ throw new Error(
59
+ `Invalid ${name} value: "${value}". Expected a non-negative number of seconds.`
60
+ );
61
+ }
62
+ return seconds;
63
+ }
64
+
65
+ /**
66
+ * Parse a size in bytes; k, m, g and t suffixes are 1024-based.
67
+ * @param {string} value - Raw value
68
+ * @returns {number} Bytes
69
+ */
70
+ function parseSizeHint(value) {
71
+ const match = /^(\d+)([bkmgt])?$/.exec(String(value).trim().toLowerCase());
72
+ const bytes = match ? Number(match[1]) * UNITS[match[2] || ''] : NaN;
73
+ if (!Number.isSafeInteger(bytes)) {
74
+ throw new Error(
75
+ `Invalid --snapshot-size-hint value: "${value}". Expected a number of bytes (k, m, g, t suffixes allowed).`
76
+ );
77
+ }
78
+ return bytes;
79
+ }
80
+
81
+ function parseValue(flag, value) {
82
+ if (flag === '--snapshot-size-hint') {
83
+ return parseSizeHint(value);
84
+ }
85
+ if (flag === '--snapshot-prepare') {
86
+ if (String(value).trim() === '') {
87
+ throw new Error('--snapshot-prepare requires a non-empty command');
88
+ }
89
+ return value;
90
+ }
91
+ return parseSeconds(value, flag);
92
+ }
93
+
94
+ /**
95
+ * Parse one snapshot option.
96
+ * @returns {number} Arguments consumed, 0 when `args[index]` is not one
97
+ */
98
+ function parse(args, index, options) {
99
+ const arg = args[index];
100
+ const equals = arg.indexOf('=');
101
+ const flag = equals === -1 ? arg : arg.slice(0, equals);
102
+ const field = FIELDS[flag];
103
+ if (!field) {
104
+ return 0;
105
+ }
106
+ if (equals !== -1) {
107
+ options[field] = parseValue(flag, arg.slice(equals + 1));
108
+ return 1;
109
+ }
110
+ const value = args[index + 1];
111
+ // A prepare command may itself start with a dash only through `=`.
112
+ if (value === undefined || value.startsWith('-')) {
113
+ const kind = {
114
+ '--snapshot-size-hint': 'bytes',
115
+ '--snapshot-prepare': 'command',
116
+ }[flag];
117
+ throw new Error(`Option ${flag} requires a ${kind || 'seconds'} argument`);
118
+ }
119
+ options[field] = parseValue(flag, value);
120
+ return 2;
121
+ }
122
+
123
+ function hasValue(value) {
124
+ return value !== null && value !== undefined;
125
+ }
126
+
127
+ /**
128
+ * Reject snapshot options outside `--resume` and fill the waits from the
129
+ * environment.
130
+ * @param {object} options - Parsed wrapper options
131
+ * @param {object} [env] - Environment
132
+ */
133
+ function validate(options, env = process.env) {
134
+ if (!hasValue(options.resume)) {
135
+ for (const [flag, field] of Object.entries(FIELDS)) {
136
+ if (hasValue(options[field])) {
137
+ throw new Error(`${flag} is only valid with --resume`);
138
+ }
139
+ }
140
+ return;
141
+ }
142
+ for (const [field, [name, flag]] of Object.entries(ENV_DEFAULTS)) {
143
+ if (!hasValue(options[field]) && hasValue(env[name]) && env[name] !== '') {
144
+ options[field] = parseSeconds(env[name], `${name} (${flag})`);
145
+ }
146
+ }
147
+ if (hasValue(options.removeOriginalAfter)) {
148
+ options.removeOriginal = true;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The `snapshotOptions` `resumeExecution` reads from the wrapper options.
154
+ * @param {object} options - Parsed wrapper options
155
+ * @returns {object} {waitSeconds, diskWaitSeconds, sizeHint, prepare}
156
+ */
157
+ function snapshotResumeSettings(options = {}) {
158
+ return {
159
+ waitSeconds: options.snapshotWait ?? 0,
160
+ diskWaitSeconds: options.snapshotDiskWait ?? 0,
161
+ sizeHint: options.snapshotSizeHint ?? null,
162
+ prepare: options.snapshotPrepare ?? null,
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ FIELDS,
168
+ createSnapshotOptionDefaults,
169
+ parse,
170
+ parseSeconds,
171
+ parseSizeHint,
172
+ snapshotResumeSettings,
173
+ validate,
174
+ };
@@ -7,11 +7,12 @@
7
7
  * - Text: Human-readable text format
8
8
  */
9
9
 
10
- const { execSync, spawnSync } = require('child_process');
10
+ const { execSync } = require('child_process');
11
+ const { escapeForLinksNotation } = require('./output-blocks');
11
12
  const {
12
- escapeForLinksNotation,
13
- formatAsNestedLinksNotation,
14
- } = require('./output-blocks');
13
+ appendLinksValue,
14
+ formatTextOptionValue,
15
+ } = require('./status-links-notation');
15
16
  const { collectProcessIds } = require('./execution-control');
16
17
  const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
17
18
  const {
@@ -27,6 +28,11 @@ const {
27
28
  const { normalizeDockerTimestamp } = require('./docker-post-mortem');
28
29
  const { END_TIME_SOURCE } = require('./detached-finalize');
29
30
  const { formatCgroupMemory } = require('./cgroup-memory');
31
+ const {
32
+ attachContainerRemoval,
33
+ dockerInspect,
34
+ persistContainerRemoval,
35
+ } = require('./container-removal');
30
36
 
31
37
  /**
32
38
  * Inspect the live state of a detached docker container by name.
@@ -43,32 +49,31 @@ const { formatCgroupMemory } = require('./cgroup-memory');
43
49
  * instead of fabricating a terminal `-1` result.
44
50
  *
45
51
  * @param {string} sessionName - Container name
52
+ * @param {Object|null} [probe] - Receives `sessionNotFound` (issue #225)
46
53
  * @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
47
54
  * or null when the container cannot be inspected (not found yet, removed, or docker error)
48
55
  */
49
- function inspectDockerState(sessionName) {
50
- const result = spawnSync(
56
+ function inspectDockerState(sessionName, probe = null) {
57
+ const { stdout, notFound } = dockerInspect(
51
58
  getDockerCommand(),
52
- [
53
- 'inspect',
54
- '-f',
55
- '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
56
- '{{.State.StartedAt}} {{.State.FinishedAt}}',
57
- sessionName,
58
- ],
59
- getDockerSpawnOptions({
60
- encoding: 'utf8',
61
- env: process.env,
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- })
59
+ dockerProbeSpawnOptions(),
60
+ '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
61
+ '{{.State.StartedAt}} {{.State.FinishedAt}}',
62
+ sessionName
64
63
  );
65
- if (result.error || result.status !== 0 || !result.stdout) {
64
+ if (probe) {
65
+ // "No such container" is a fact, unlike a docker that cannot be reached
66
+ // (issue #225); it still reads as unknown liveness here (issue #136).
67
+ probe.sessionNotFound = notFound;
68
+ }
69
+ if (stdout === null) {
66
70
  return null;
67
71
  }
68
72
  // Parsed positionally and defensively: an older docker (or a stubbed one)
69
73
  // may answer with fewer fields than the template asks for.
70
- const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
71
- result.stdout.trim().split(/\s+/);
74
+ const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] = stdout
75
+ .trim()
76
+ .split(/\s+/);
72
77
  const exitCode = Number.parseInt(exitRaw, 10);
73
78
  return {
74
79
  running: runningRaw === 'true',
@@ -89,12 +94,20 @@ function isDetachedDockerRecord(record) {
89
94
  );
90
95
  }
91
96
 
92
- function readDockerState(record) {
97
+ function dockerProbeSpawnOptions() {
98
+ return getDockerSpawnOptions({
99
+ encoding: 'utf8',
100
+ env: process.env,
101
+ stdio: ['pipe', 'pipe', 'pipe'],
102
+ });
103
+ }
104
+
105
+ function readDockerState(record, probe = null) {
93
106
  const opts = record.options || {};
94
107
  if (opts.isolated !== 'docker' || !opts.sessionName) {
95
108
  return null;
96
109
  }
97
- const state = inspectDockerState(opts.sessionName);
110
+ const state = inspectDockerState(opts.sessionName, probe);
98
111
  if (
99
112
  record.attempt &&
100
113
  state?.startedAt &&
@@ -325,14 +338,14 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
325
338
  enriched.observedAt = enriched.endTime;
326
339
  }
327
340
 
328
- function resolveDetachedStatus(record, logTail) {
341
+ function resolveDetachedStatus(record, logTail, probe = null) {
329
342
  if (require('./launch-owner').hasActiveLaunch(record)) {
330
343
  return record;
331
344
  }
332
345
  const footer = parseFooterFromTail(logTail);
333
346
  const footerExit = footer.exitCode;
334
347
  const dockerState = isDetachedDockerRecord(record)
335
- ? readDockerState(record)
348
+ ? readDockerState(record, probe)
336
349
  : null;
337
350
  const alive = isDetachedDockerRecord(record)
338
351
  ? dockerState === null
@@ -550,7 +563,14 @@ function enrichDetachedStatus(record) {
550
563
  const logTail = record
551
564
  ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
552
565
  : null;
553
- const resolved = resolveDetachedStatus(record, logTail);
566
+ const probe = {
567
+ dockerCommand: getDockerCommand(),
568
+ spawnOptions: dockerProbeSpawnOptions(),
569
+ };
570
+ const resolved = attachContainerRemoval(
571
+ resolveDetachedStatus(record, logTail, probe),
572
+ probe
573
+ );
554
574
  const enriched = attachMemoryExhaustion(
555
575
  attachExitReason(resolved, logTail),
556
576
  logTail
@@ -671,22 +691,21 @@ function formatRecordAsLinksNotation(record) {
671
691
  for (const [key, value] of Object.entries(obj)) {
672
692
  if (value !== null && value !== undefined) {
673
693
  if (key === 'options' && typeof value === 'object') {
674
- // Format options as nested Links notation
694
+ // Nested option values (launchOwner, exitEvidence, resolvedLimits…)
695
+ // are rendered as nested Links notation, never "[object Object]"
696
+ // (issue #226).
675
697
  const optionEntries = Object.entries(value).filter(
676
698
  ([, v]) => v !== null && v !== undefined
677
699
  );
678
700
  if (optionEntries.length > 0) {
679
701
  lines.push(' options');
680
702
  for (const [optKey, optValue] of optionEntries) {
681
- const formattedOptValue = escapeForLinksNotation(optValue);
682
- lines.push(` ${optKey} ${formattedOptValue}`);
703
+ appendLinksValue(lines, optKey, optValue, 4);
683
704
  }
684
705
  }
685
706
  } else if (typeof value === 'object') {
686
- // For other objects, still format as nested Links notation
687
- lines.push(` ${key}`);
688
- const nested = formatAsNestedLinksNotation(value, 2, 2);
689
- lines.push(nested);
707
+ // Other objects use the same nested layout as the Rust formatter.
708
+ appendLinksValue(lines, key, value, 2);
690
709
  } else {
691
710
  const formattedValue = escapeForLinksNotation(value);
692
711
  lines.push(` ${key} ${formattedValue}`);
@@ -751,6 +770,11 @@ function formatRecordAsText(record) {
751
770
  if (obj.staleDetectedAt) {
752
771
  lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
753
772
  }
773
+ if (obj.containerState) {
774
+ // The container no longer exists; `containerId` is history (issue #225).
775
+ lines.push(`Container State: ${obj.containerState}`);
776
+ lines.push(`Container Removed: ${obj.containerRemovedObservedAt || 'N/A'}`);
777
+ }
754
778
  lines.push(`Log Path: ${obj.logPath}`);
755
779
  if (obj.attempt) {
756
780
  lines.push(
@@ -770,7 +794,7 @@ function formatRecordAsText(record) {
770
794
  if (optionEntries.length > 0) {
771
795
  lines.push(`Options:`);
772
796
  for (const [key, value] of optionEntries) {
773
- lines.push(` ${key}: ${value}`);
797
+ lines.push(` ${key}: ${formatTextOptionValue(value)}`);
774
798
  }
775
799
  }
776
800
 
@@ -897,6 +921,7 @@ function queryStatus(store, identifier, outputFormat) {
897
921
  try {
898
922
  // Enrich detached execution status with live session check
899
923
  const enrichedRecord = enrichDetachedStatus(record);
924
+ persistContainerRemoval(store, record, enrichedRecord);
900
925
  const withProcessIds = attachProcessIds(enrichedRecord);
901
926
  // Attach currentTime so callers can see how long an executing command has been running
902
927
  const withCurrentTime = attachCurrentTime(withProcessIds);