start-command 0.33.0 → 0.34.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,289 @@
1
+ /**
2
+ * Container post-mortem facts (issues #170, #171).
3
+ *
4
+ * When a detached container dies the only thing `start` used to write into the
5
+ * log was `Reason: exitCode=137 oomKilled=false` — the two facts that, together,
6
+ * say the least. `137` is `128 + 9`, i.e. SIGKILL, and `oomKilled=false` rules
7
+ * out the cgroup OOM killer, so the container was killed from the outside
8
+ * (`docker kill`, a `docker stop` escalation, systemd, the CI runner). None of
9
+ * that was said out loud, and the three fields that would have shown *when* it
10
+ * happened — `State.StartedAt`, `State.FinishedAt`, `State.Error` — were never
11
+ * read at all, even though the watcher was already one `docker inspect` away
12
+ * from them.
13
+ *
14
+ * This module owns those facts in one place:
15
+ * - `describeExitCode()` decodes `128 + n` once, for the watcher (completion
16
+ * time, written into the log) and for `--status` (query time, in memory), so
17
+ * the two can never disagree (issue #171);
18
+ * - the `build*Snippet()` helpers generate the POSIX shell the detached watcher
19
+ * runs, including the signal table, so the shell and the runtime share a
20
+ * single source of truth;
21
+ * - `formatContainerPostMortem()` renders the same block for callers that
22
+ * already hold the facts in memory (the attached docker path).
23
+ */
24
+
25
+ const {
26
+ SIGNAL_NAMES,
27
+ UNKNOWN_EXIT_CODE,
28
+ describeExitCode,
29
+ } = require('./exit-reason');
30
+ const { shellQuote } = require('./isolation-log-utils');
31
+
32
+ /**
33
+ * Docker's zero value for a timestamp that was never set. `State.FinishedAt`
34
+ * carries it for every container that has not finished, and `State.StartedAt`
35
+ * for every container that never started. It must never reach a record as an
36
+ * `endTime`: a year-1 timestamp is worse than an honest `null`.
37
+ */
38
+ const DOCKER_ZERO_TIME = '0001-01-01T00:00:00Z';
39
+
40
+ /** Placeholder written instead of a fact that could not be observed. */
41
+ const UNKNOWN = UNKNOWN_EXIT_CODE;
42
+
43
+ /** Placeholder for `State.Error` when docker reported no error at all. */
44
+ const NO_ERROR = '(none)';
45
+
46
+ /** Header line of the post-mortem block appended to a kept container's log. */
47
+ const POST_MORTEM_HEADER = '=== Container post-mortem ===';
48
+
49
+ /**
50
+ * Normalize a timestamp reported by `docker inspect`.
51
+ * @param {string|null|undefined} value - Raw `State.StartedAt`/`State.FinishedAt`
52
+ * @returns {string|null} The timestamp, or null when docker reported no time
53
+ */
54
+ function normalizeDockerTimestamp(value) {
55
+ const text = String(
56
+ value === null || value === undefined ? '' : value
57
+ ).trim();
58
+ if (!text || text === DOCKER_ZERO_TIME || text === UNKNOWN) {
59
+ return null;
60
+ }
61
+ if (text === '<no value>' || !Number.isFinite(Date.parse(text))) {
62
+ return null;
63
+ }
64
+ return text;
65
+ }
66
+
67
+ /**
68
+ * Format the time a container was alive.
69
+ * @param {string|null} startedAt - `State.StartedAt`
70
+ * @param {string|null} finishedAt - `State.FinishedAt`
71
+ * @returns {string|null} Lifetime such as `5.798s`, or null when unknown
72
+ */
73
+ function formatLifetime(startedAt, finishedAt) {
74
+ const start = normalizeDockerTimestamp(startedAt);
75
+ const end = normalizeDockerTimestamp(finishedAt);
76
+ if (!start || !end) {
77
+ return null;
78
+ }
79
+ const ms = Date.parse(end) - Date.parse(start);
80
+ if (!Number.isFinite(ms) || ms < 0) {
81
+ return null;
82
+ }
83
+ return `${Math.floor(ms / 1000)}.${String(ms % 1000).padStart(3, '0')}s`;
84
+ }
85
+
86
+ /**
87
+ * Render the post-mortem block for facts already held in memory.
88
+ * @param {object} facts - Container name and inspected `State` fields
89
+ * @returns {string} Block text, newline terminated
90
+ */
91
+ function formatContainerPostMortem(facts = {}) {
92
+ const startedAt = normalizeDockerTimestamp(facts.startedAt);
93
+ const finishedAt = normalizeDockerTimestamp(facts.finishedAt);
94
+ const lifetime = formatLifetime(facts.startedAt, facts.finishedAt);
95
+ const error = String(facts.error || '').trim();
96
+ return [
97
+ POST_MORTEM_HEADER,
98
+ `Container: ${facts.containerName || UNKNOWN}`,
99
+ `Exit Code: ${describeExitCode(facts.exitCode).text}`,
100
+ `OOMKilled: ${facts.oomKilled === undefined || facts.oomKilled === null ? UNKNOWN : facts.oomKilled}`,
101
+ `StartedAt: ${startedAt || UNKNOWN}`,
102
+ `FinishedAt: ${finishedAt || UNKNOWN}`,
103
+ `Lifetime: ${lifetime || UNKNOWN}`,
104
+ `Error: ${error || NO_ERROR}`,
105
+ '',
106
+ ].join('\n');
107
+ }
108
+
109
+ /**
110
+ * Render the single line written when a container is removed, so a successful
111
+ * run keeps its log terse while still carrying the same facts (issue #171).
112
+ * @param {object} facts - Container name and inspected `State` fields
113
+ * @returns {string} One line, newline terminated
114
+ */
115
+ function formatContainerRemovalNote(facts = {}) {
116
+ const described = describeExitCode(facts.exitCode);
117
+ const signal = described.signal ? `, ${described.signal}` : '';
118
+ const lifetime = formatLifetime(facts.startedAt, facts.finishedAt) || UNKNOWN;
119
+ const oomKilled =
120
+ facts.oomKilled === undefined || facts.oomKilled === null
121
+ ? UNKNOWN
122
+ : facts.oomKilled;
123
+ return (
124
+ `Container removed: ${facts.containerName || UNKNOWN} ` +
125
+ `(exit ${described.code === null ? UNKNOWN : described.code}${signal}, ` +
126
+ `lifetime ${lifetime}, oomKilled=${oomKilled})\n`
127
+ );
128
+ }
129
+
130
+ /** Shell variables the completion watcher fills in and the snippets consume. */
131
+ const SHELL_VARS = {
132
+ exit: '__start_command_exit',
133
+ oom: '__start_command_oom',
134
+ started: '__start_command_started',
135
+ finished: '__start_command_finished',
136
+ error: '__start_command_error',
137
+ errorText: '__start_command_error_text',
138
+ signal: '__start_command_signal',
139
+ exitText: '__start_command_exit_text',
140
+ lifetime: '__start_command_lifetime',
141
+ };
142
+
143
+ /** `docker inspect -f` template collecting every post-mortem fact at once. */
144
+ const DOCKER_STATE_INSPECT_FORMAT =
145
+ '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
146
+
147
+ /**
148
+ * Shell fragment reading the container's terminal state.
149
+ *
150
+ * The four whitespace-free fields are split with `set --` rather than the
151
+ * `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
152
+ * reach the first and the last field. `State.Error` is read separately because
153
+ * it is free-form text that can contain spaces.
154
+ *
155
+ * @param {string} containerName - Container to inspect
156
+ * @returns {string} Shell command
157
+ */
158
+ function buildDockerStateSnippet(containerName) {
159
+ const quotedName = shellQuote(containerName);
160
+ const v = SHELL_VARS;
161
+ return [
162
+ `__start_command_state=$(docker inspect -f '${DOCKER_STATE_INSPECT_FORMAT}' ${quotedName} 2>/dev/null || printf '%s' '-1 false ${UNKNOWN} ${UNKNOWN}')`,
163
+ 'set -- $__start_command_state',
164
+ `${v.exit}=\${1:--1}`,
165
+ `${v.oom}=\${2:-false}`,
166
+ `${v.started}=\${3:-${UNKNOWN}}`,
167
+ `${v.finished}=\${4:-${UNKNOWN}}`,
168
+ `${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
169
+ `case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
170
+ `case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
171
+ buildSignalDecodeSnippet(),
172
+ buildLifetimeSnippet(),
173
+ ].join('; ');
174
+ }
175
+
176
+ /**
177
+ * Shell fragment decoding `128 + n` into a signal name.
178
+ *
179
+ * The `case` arms are generated from the very table `describeExitCode()` uses,
180
+ * so the log the watcher writes and the `exitReason` `--status` computes can
181
+ * never name different signals for the same code (issue #171).
182
+ *
183
+ * @returns {string} Shell command
184
+ */
185
+ function buildSignalDecodeSnippet() {
186
+ const v = SHELL_VARS;
187
+ const arms = Object.entries(SIGNAL_NAMES)
188
+ .map(([signal, name]) => `${128 + Number(signal)}) ${v.signal}=${name};;`)
189
+ .join(' ');
190
+ return [
191
+ `${v.signal}=''`,
192
+ `case "$${v.exit}" in ${arms} esac`,
193
+ `${v.exitText}="$${v.exit}"`,
194
+ `if [ -n "$${v.signal}" ]; then ${v.exitText}="$${v.exit} ($${v.signal} - 128+$((${v.exit} - 128)))"; fi`,
195
+ ].join('; ');
196
+ }
197
+
198
+ /**
199
+ * Shell fragment computing how long the container was alive.
200
+ *
201
+ * Best effort by design: `date -d` is GNU, `date -j -f` is BSD, and a host that
202
+ * offers neither leaves the lifetime `unknown` rather than guessing.
203
+ *
204
+ * BSD `date` cannot parse the fractional seconds docker reports, so the
205
+ * fallback strips them before parsing and adds them back afterwards. Truncating
206
+ * both timestamps to whole seconds instead would *not* cancel out: a container
207
+ * alive from `.942` to `.740` of the next-but-five second measures 5.798s but
208
+ * truncates to 6.000s, and the error can reach a full second either way.
209
+ *
210
+ * @returns {string} Shell command
211
+ */
212
+ function buildLifetimeSnippet() {
213
+ const v = SHELL_VARS;
214
+ return [
215
+ '__start_command_millis() { ' +
216
+ 'case "$1" in *.*) __start_command_frac=${1#*.};; ' +
217
+ "*) printf '000'; return;; esac; " +
218
+ '__start_command_frac=${__start_command_frac%%[!0-9]*}; ' +
219
+ 'printf \'%.3s\' "${__start_command_frac}000"; }',
220
+ '__start_command_epoch_ms() { ' +
221
+ '__start_command_ts=$(date -u -d "$1" +%s%3N 2>/dev/null); ' +
222
+ 'case "$__start_command_ts" in \'\'|*[!0-9]*) ' +
223
+ '__start_command_ts=$(date -u -j -f \'%Y-%m-%dT%H:%M:%S\' "${1%.*}" +%s 2>/dev/null) && ' +
224
+ '__start_command_ts="${__start_command_ts}$(__start_command_millis "$1")";; esac; ' +
225
+ "case \"$__start_command_ts\" in ''|*[!0-9]*) __start_command_ts='';; esac; " +
226
+ 'printf \'%s\' "$__start_command_ts"; }',
227
+ `${v.lifetime}=${UNKNOWN}`,
228
+ `__start_command_t0=$(__start_command_epoch_ms "$${v.started}")`,
229
+ `__start_command_t1=$(__start_command_epoch_ms "$${v.finished}")`,
230
+ 'if [ -n "$__start_command_t0" ] && [ -n "$__start_command_t1" ] && ' +
231
+ '[ "$__start_command_t1" -ge "$__start_command_t0" ] 2>/dev/null; then ' +
232
+ '__start_command_ms=$((__start_command_t1 - __start_command_t0)); ' +
233
+ `${v.lifetime}="$((__start_command_ms / 1000)).$(printf '%03d' "$((__start_command_ms % 1000))")s"; fi`,
234
+ ].join('; ');
235
+ }
236
+
237
+ /**
238
+ * Shell fragment appending the post-mortem block for a kept container.
239
+ * @param {string} containerName - Container that was kept
240
+ * @param {string} quotedLogPath - Already shell-quoted log path
241
+ * @returns {string} Shell command
242
+ */
243
+ function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
244
+ const v = SHELL_VARS;
245
+ return (
246
+ `${v.errorText}="$${v.error}"; ` +
247
+ `if [ -z "$${v.errorText}" ]; then ${v.errorText}='${NO_ERROR}'; fi; ` +
248
+ `printf '\\n${POST_MORTEM_HEADER}\\nContainer: %s\\nExit Code: %s\\n` +
249
+ `OOMKilled: %s\\nStartedAt: %s\\nFinishedAt: %s\\nLifetime: %s\\nError: %s\\n' ` +
250
+ `${shellQuote(containerName)} "$${v.exitText}" "$${v.oom}" ` +
251
+ `"$${v.started}" "$${v.finished}" "$${v.lifetime}" "$${v.errorText}" >> ${quotedLogPath}`
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Shell fragment appending the one-line note for a removed container.
257
+ * @param {string} containerName - Container that was removed
258
+ * @param {string} quotedLogPath - Already shell-quoted log path
259
+ * @returns {string} Shell command
260
+ */
261
+ function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
262
+ const v = SHELL_VARS;
263
+ return (
264
+ `__start_command_signal_note=''; ` +
265
+ `if [ -n "$${v.signal}" ]; then __start_command_signal_note=", $${v.signal}"; fi; ` +
266
+ `printf '\\nContainer removed: %s (exit %s%s, lifetime %s, oomKilled=%s)\\n' ` +
267
+ `${shellQuote(containerName)} "$${v.exit}" "$__start_command_signal_note" ` +
268
+ `"$${v.lifetime}" "$${v.oom}" >> ${quotedLogPath}`
269
+ );
270
+ }
271
+
272
+ module.exports = {
273
+ DOCKER_STATE_INSPECT_FORMAT,
274
+ DOCKER_ZERO_TIME,
275
+ NO_ERROR,
276
+ POST_MORTEM_HEADER,
277
+ SHELL_VARS,
278
+ UNKNOWN,
279
+ buildDockerPostMortemSnippet,
280
+ buildDockerRemovalNoteSnippet,
281
+ buildDockerStateSnippet,
282
+ buildLifetimeSnippet,
283
+ buildSignalDecodeSnippet,
284
+ describeExitCode,
285
+ formatContainerPostMortem,
286
+ formatContainerRemovalNote,
287
+ formatLifetime,
288
+ normalizeDockerTimestamp,
289
+ };
@@ -130,7 +130,8 @@ function resumeAllExecutions(store, deps = {}) {
130
130
  startWatcher(
131
131
  sessionName,
132
132
  getDockerContainerCleanupPolicy(record.options || {}),
133
- record.logPath || null
133
+ record.logPath || null,
134
+ record.uuid || null
134
135
  );
135
136
  executions.push(
136
137
  describeRecord(
@@ -411,7 +411,8 @@ async function resumeExecution(store, identifier, deps = {}) {
411
411
  startWatcher(
412
412
  activeSessionName(plan),
413
413
  getDockerContainerCleanupPolicy(record.options || {}),
414
- record.logPath || null
414
+ record.logPath || null,
415
+ record.uuid || null
415
416
  );
416
417
  }
417
418
 
@@ -75,6 +75,30 @@ class ExecutionRecord {
75
75
  this.logPath = options.logPath || '';
76
76
  this.startTime = options.startTime || new Date().toISOString();
77
77
  this.endTime = options.endTime || null;
78
+ // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
79
+ // finish time with `new Date()` whenever the record had none, which made an
80
+ // observation indistinguishable from a real finish time. One of
81
+ // 'docker-finished-at' | 'log-footer' | 'observed-at'.
82
+ this.endTimeSource =
83
+ options.endTimeSource !== undefined ? options.endTimeSource : undefined;
84
+ // When the end of the execution was *noticed*, for the cases where no real
85
+ // finish time exists. Always accompanies endTimeSource === 'observed-at'.
86
+ this.observedAt =
87
+ options.observedAt !== undefined ? options.observedAt : undefined;
88
+ // When `cleanupStale()` gave up on a record whose process is gone. This is
89
+ // a detection time, not a finish time, so it is never written to `endTime`
90
+ // (issue #170.3).
91
+ this.staleDetectedAt =
92
+ options.staleDetectedAt !== undefined
93
+ ? options.staleDetectedAt
94
+ : undefined;
95
+ // `docker inspect .State.StartedAt` of the isolation container, kept next
96
+ // to `endTime` so the container's lifetime stays reconstructible after the
97
+ // container itself is gone (issue #171).
98
+ this.containerStartedAt =
99
+ options.containerStartedAt !== undefined
100
+ ? options.containerStartedAt
101
+ : undefined;
78
102
  this.oomKilled =
79
103
  options.oomKilled !== undefined ? options.oomKilled : undefined;
80
104
  // Query-time hint explaining an opaque exit code (issue #162). Derived from
@@ -122,6 +146,21 @@ class ExecutionRecord {
122
146
  startTime: this.startTime,
123
147
  endTime: this.endTime,
124
148
  };
149
+ if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
150
+ obj.endTimeSource = this.endTimeSource;
151
+ }
152
+ if (this.observedAt !== undefined && this.observedAt !== null) {
153
+ obj.observedAt = this.observedAt;
154
+ }
155
+ if (this.staleDetectedAt !== undefined && this.staleDetectedAt !== null) {
156
+ obj.staleDetectedAt = this.staleDetectedAt;
157
+ }
158
+ if (
159
+ this.containerStartedAt !== undefined &&
160
+ this.containerStartedAt !== null
161
+ ) {
162
+ obj.containerStartedAt = this.containerStartedAt;
163
+ }
125
164
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
126
165
  obj.oomKilled = this.oomKilled;
127
166
  }
@@ -662,10 +701,15 @@ class ExecutionStore {
662
701
  (r) => r.uuid === staleRecord.uuid
663
702
  );
664
703
  if (index >= 0) {
665
- // Mark as executed with exit code -1 to indicate abnormal termination
704
+ // Mark as executed with exit code -1 to indicate abnormal
705
+ // termination. The real finish time is unknowable here — the
706
+ // process vanished without writing one — so the record keeps
707
+ // `endTime: null` and states *when the loss was detected* instead
708
+ // of passing cleanup time off as a finish time (issue #170.3).
666
709
  currentRecords[index].status = ExecutionStatus.EXECUTED;
667
710
  currentRecords[index].exitCode = -1;
668
- currentRecords[index].endTime = new Date().toISOString();
711
+ currentRecords[index].endTime = null;
712
+ currentRecords[index].staleDetectedAt = new Date().toISOString();
669
713
  result.cleaned++;
670
714
  }
671
715
  }
@@ -58,6 +58,9 @@ const EXIT_REASON_MARKERS = [
58
58
  * Only the signals that actually show up in command logs are named.
59
59
  * @type {Object<number, string>}
60
60
  */
61
+ /** Rendered when an exit code is unavailable or not numeric. */
62
+ const UNKNOWN_EXIT_CODE = 'unknown';
63
+
61
64
  const SIGNAL_NAMES = {
62
65
  1: 'SIGHUP',
63
66
  2: 'SIGINT',
@@ -194,6 +197,31 @@ function signalNameForExitCode(exitCode) {
194
197
  return SIGNAL_NAMES[exitCode - 128] || null;
195
198
  }
196
199
 
200
+ /**
201
+ * Describe a terminal exit code, decoding the `128+n` signal convention.
202
+ *
203
+ * Single source of truth for issue #171.4: the detached Docker watcher writes
204
+ * this text into the log at completion time (through the shell it generates),
205
+ * and `--status` renders the same text at query time from the stored record.
206
+ * Both used to decode `128+n` on their own, so they could disagree.
207
+ *
208
+ * @param {number|string|null|undefined} exitCode - Terminal exit code
209
+ * @returns {{code: number|null, signal: string|null, text: string}} Description
210
+ */
211
+ function describeExitCode(exitCode) {
212
+ const code =
213
+ typeof exitCode === 'number' ? exitCode : Number.parseInt(exitCode, 10);
214
+ if (!Number.isFinite(code)) {
215
+ return { code: null, signal: null, text: UNKNOWN_EXIT_CODE };
216
+ }
217
+ const signal = signalNameForExitCode(code);
218
+ return {
219
+ code,
220
+ signal,
221
+ text: signal ? `${code} (${signal} - 128+${code - 128})` : String(code),
222
+ };
223
+ }
224
+
197
225
  /**
198
226
  * Resolve the best available hint for why an execution ended.
199
227
  *
@@ -223,12 +251,14 @@ function resolveExitReason(input) {
223
251
 
224
252
  module.exports = {
225
253
  EXIT_REASON_MARKERS,
254
+ UNKNOWN_EXIT_CODE,
226
255
  MEMORY_EXHAUSTION_PREFIX,
227
256
  SIGNAL_NAMES,
228
257
  detectExitReason,
229
258
  detectMemoryMarker,
230
259
  findExitReasonMarker,
231
260
  resolveMemoryExhaustion,
261
+ describeExitCode,
232
262
  resolveExitReason,
233
263
  signalNameForExitCode,
234
264
  };
@@ -4,6 +4,7 @@ const { spawn } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
+ const { describeExitCode } = require('./exit-reason');
7
8
 
8
9
  function getTempRoot() {
9
10
  return process.env.START_TEMP_ROOT || path.join(os.tmpdir(), 'start-command');
@@ -158,8 +159,11 @@ function shellQuote(value) {
158
159
  }
159
160
 
160
161
  function createShellLogFooterSnippet() {
162
+ // UTC, to match `createLogFooter()` (which formats an ISO timestamp). Two
163
+ // writers of the same `Finished:` line disagreeing on the timezone makes the
164
+ // footer useless as a source for `endTime` (issue #170.2).
161
165
  const dateCommand =
162
- "date '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S'";
166
+ "date -u '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date -u '+%Y-%m-%d %H:%M:%S'";
163
167
  return `printf '\\n==================================================\\nFinished: %s\\nExit Code: %s\\n' "$(${dateCommand})" "$__start_command_exit"`;
164
168
  }
165
169
 
@@ -260,6 +264,9 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
260
264
  }
261
265
 
262
266
  module.exports = {
267
+ // Re-exported so the log writers and the status formatter share one decoder
268
+ // for the `128 + n` signal convention (issue #171.4).
269
+ describeExitCode,
263
270
  LOG_TAIL_BYTES,
264
271
  FATAL_MARKER_TAIL_BYTES,
265
272
  readLogTail,
@@ -28,12 +28,13 @@ const {
28
28
  } = require('./isolation-log-utils');
29
29
  const {
30
30
  buildAttachedDockerKeptMessage,
31
+ readDockerContainerState,
32
+ recordAttachedDockerPostMortem,
31
33
  DOCKER_CONTAINER_CLEANUP_POLICY,
32
34
  getDockerContainerCleanupPolicy,
33
35
  shouldCleanupDockerContainer,
34
36
  getDockerContainerCleanupInstructions,
35
37
  appendDockerContainerCleanupPolicyMessage,
36
- readDockerContainerOomKilled,
37
38
  readDockerContainerStatus,
38
39
  removeDockerContainer,
39
40
  startDetachedDockerCompletionWatcher,
@@ -734,7 +735,8 @@ function runInDocker(command, options = {}) {
734
735
  startDetachedDockerCompletionWatcher(
735
736
  containerName,
736
737
  cleanupPolicy,
737
- options.logPath
738
+ options.logPath,
739
+ options.executionId || null
738
740
  );
739
741
 
740
742
  let message = `Command started in detached docker container: ${containerName}`;
@@ -829,7 +831,15 @@ function runInDocker(command, options = {}) {
829
831
  message += `\n${hint}`;
830
832
  }
831
833
 
832
- const oomKilled = readDockerContainerOomKilled(containerName);
834
+ const dockerState = readDockerContainerState(containerName);
835
+ const oomKilled = dockerState ? dockerState.oomKilled : null;
836
+ const postMortem = (removed) =>
837
+ recordAttachedDockerPostMortem({
838
+ containerName,
839
+ state: dockerState,
840
+ logPath: options.logPath,
841
+ removed,
842
+ });
833
843
  const launchFailed =
834
844
  !containerExistedBeforeLaunch &&
835
845
  readDockerContainerStatus(containerName) === 'created';
@@ -844,23 +854,23 @@ function runInDocker(command, options = {}) {
844
854
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
845
855
  ) {
846
856
  if (removeDockerContainer(containerName, options.logPath)) {
847
- message += `\nContainer removed after completion.`;
857
+ message += `\nContainer removed after completion.${postMortem(true)}`;
848
858
  } else {
849
859
  message += `\nWarning: failed to remove container automatically.`;
850
860
  message += `\nRemove when done: docker rm -f ${containerName}`;
851
861
  }
852
862
  } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
853
- message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
863
+ message += `\n${getDockerContainerCleanupInstructions(containerName)}${postMortem(false)}`;
854
864
  } else if (
855
865
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
856
866
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
857
867
  ) {
858
- message += buildAttachedDockerKeptMessage({
868
+ message += `${buildAttachedDockerKeptMessage({
859
869
  containerName,
860
870
  exitCode,
861
871
  oomKilled,
862
872
  logPath: options.logPath,
863
- });
873
+ })}${postMortem(false)}`;
864
874
  }
865
875
 
866
876
  resolve({