start-command 0.35.2 → 0.35.4

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # start-command
2
2
 
3
+ ## 0.35.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 6544236: Scope explicit resume status, memory evidence and log reads to execution attempts; preserve earlier evidence in attempt history and report launch, watcher, output and terminal lifecycle separately.
8
+
9
+ ## 0.35.3
10
+
11
+ ### Patch Changes
12
+
13
+ - ec0a9cf: Report OOM kill scope as unknown when only cgroup memory counters are available. Allocation events and killed processes have different units, so their comparison cannot establish container, parent or host scope. Preserve raw counters and the exit-code guard for earlier child OOM kills.
14
+
3
15
  ## 0.35.2
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.35.2",
3
+ "version": "0.35.4",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -5,14 +5,16 @@
5
5
  * it says that *some* process in the container was OOM-killed at some point,
6
6
  * but not how many, not whether the container hit its own `--memory` limit or
7
7
  * the whole host ran out, and not how close the execution came to its limit.
8
- * The kernel keeps all three per cgroup
8
+ * The kernel provides raw counters per cgroup
9
9
  * (https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files):
10
- * memory.events `oom` the cgroup hit its own limit
11
- * memory.events `oom_kill` processes killed here by *any* OOM killer, so
12
- * `oom_kill > oom` points to a host-wide (or
13
- * parent cgroup) OOM
10
+ * memory.events `oom` allocation events reaching the memory limit
11
+ * memory.events `oom_kill` processes killed here by *any* OOM killer
14
12
  * memory.peak / memory.max peak usage (Linux 5.19+) and limit (`max` when
15
13
  * unlimited)
14
+ * These counters have different units and are hierarchical. A group OOM can
15
+ * kill several processes for one allocation event; earlier allocation events
16
+ * can also coexist with a later host OOM. Their comparison cannot establish
17
+ * container, parent or host scope (issue #185).
16
18
  *
17
19
  * The cgroup is removed when the container stops, so the values cannot be read
18
20
  * afterwards. The detached completion watcher therefore samples them while the
@@ -122,9 +124,8 @@ function buildCgroupMemoryLogSnippet(quotedLogPath) {
122
124
  // A function, so `$1`.. of the watcher script itself stay untouched.
123
125
  return (
124
126
  `__start_command_cgroup_log() { __scm_note=''; ` +
125
- `if [ "$4" -gt 0 ] 2>/dev/null; then if [ "$4" -gt "$3" ] 2>/dev/null; then ` +
126
- `__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.HOST_OR_PARENT]})'; ` +
127
- `else __scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.CONTAINER_LIMIT]})'; fi; fi; ` +
127
+ `if [ "$4" -gt 0 ] 2>/dev/null; then ` +
128
+ `__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.UNKNOWN]})'; fi; ` +
128
129
  `printf 'Memory: memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\\n' ` +
129
130
  `"$1" "$2" "$3" "$4" "$__scm_note"; }; ` +
130
131
  `if [ -n "$${v.sample}" ]; then __start_command_cgroup_log $${v.sample} >> ${quotedLogPath}; fi`
@@ -151,14 +152,17 @@ function formatCgroupMemoryLogLine(text) {
151
152
 
152
153
  /** Where the OOM killer that killed processes in the container came from. */
153
154
  const OOM_SCOPE = {
155
+ UNKNOWN: 'unknown',
156
+ // Retained scope names; raw counters alone cannot establish either one.
154
157
  CONTAINER_LIMIT: 'container-limit',
155
158
  HOST_OR_PARENT: 'host-or-parent',
156
159
  };
157
160
 
158
161
  const OOM_SCOPE_NOTES = {
162
+ [OOM_SCOPE.UNKNOWN]: 'OOM kill scope unknown',
159
163
  [OOM_SCOPE.CONTAINER_LIMIT]: 'the container hit its own memory limit',
160
164
  [OOM_SCOPE.HOST_OR_PARENT]:
161
- 'oom_kill > oom: a host-wide or parent cgroup OOM killed processes here',
165
+ 'a host-wide or parent cgroup OOM killed processes here',
162
166
  };
163
167
 
164
168
  function parseCounter(text) {
@@ -216,16 +220,17 @@ function normalizeCgroupMemory(value) {
216
220
 
217
221
  /**
218
222
  * @param {?object} memory - Counters
219
- * @returns {?string} One of OOM_SCOPE, or null when nothing was OOM-killed
223
+ * Raw counters do not attribute the OOM killer's scope. Separately attributed
224
+ * kernel/cgroup evidence would be needed to establish container, parent or
225
+ * host scope. An unknown oomEvents count remains unknown, never zero.
226
+ * @returns {?string} OOM_SCOPE.UNKNOWN for observed kills, otherwise null
220
227
  */
221
228
  function describeCgroupOomScope(memory) {
222
229
  const counters = normalizeCgroupMemory(memory);
223
230
  if (!counters || !(counters.oomKills > 0)) {
224
231
  return null;
225
232
  }
226
- return counters.oomKills > (counters.oomEvents || 0)
227
- ? OOM_SCOPE.HOST_OR_PARENT
228
- : OOM_SCOPE.CONTAINER_LIMIT;
233
+ return OOM_SCOPE.UNKNOWN;
229
234
  }
230
235
 
231
236
  function formatBytes(bytes) {
@@ -85,6 +85,17 @@ function finalizeDetachedExecution(options = {}) {
85
85
  if (!record) {
86
86
  return { updated: false, reason: 'record-not-found' };
87
87
  }
88
+ const started = normalizeDockerTimestamp(options.startedAt);
89
+ const attemptNumber = options.attemptNumber ?? null;
90
+ if (
91
+ record.attempt &&
92
+ ((attemptNumber !== null &&
93
+ Number(attemptNumber) !== record.attempt.number) ||
94
+ (started && Date.parse(started) < Date.parse(record.attempt.startedAt)) ||
95
+ (attemptNumber === null && !started))
96
+ ) {
97
+ return { updated: false, reason: 'stale-attempt' };
98
+ }
88
99
  if (record.status === 'executed' && record.endTime) {
89
100
  // Already finalized (e.g. a `--status` query got there first, or the
90
101
  // watcher ran twice after a resume). Nothing to correct.
@@ -146,11 +157,24 @@ function finalizeDetachedExecution(options = {}) {
146
157
  record.options = { ...(record.options || {}), containerError };
147
158
  }
148
159
 
160
+ const {
161
+ appendLifecycle,
162
+ readAttemptActivity,
163
+ } = require('./execution-attempt');
164
+ readAttemptActivity(record);
149
165
  try {
150
166
  store.save(record);
151
167
  } catch (err) {
152
168
  return { updated: false, reason: `save-failed: ${err.message}`, record };
153
169
  }
170
+ appendLifecycle(record, 'terminal', {
171
+ status: record.status,
172
+ exitCode: record.exitCode,
173
+ endTime: record.endTime,
174
+ exitReason: record.exitReason || null,
175
+ oomKilled: record.oomKilled ?? null,
176
+ cgroupMemory: record.cgroupMemory || null,
177
+ });
154
178
  return { updated: true, record };
155
179
  }
156
180
 
@@ -163,12 +187,10 @@ function finalizeDetachedExecution(options = {}) {
163
187
  function resolveReason(record) {
164
188
  try {
165
189
  const { resolveExitReason } = require('./exit-reason');
166
- const {
167
- FATAL_MARKER_TAIL_BYTES,
168
- readLogTail,
169
- } = require('./isolation-log-utils');
190
+ const { FATAL_MARKER_TAIL_BYTES } = require('./isolation-log-utils');
191
+ const { readAttemptLogTail } = require('./execution-attempt');
170
192
  const logTail = record.logPath
171
- ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
193
+ ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
172
194
  : null;
173
195
  return resolveExitReason({
174
196
  exitCode: record.exitCode,
@@ -251,13 +273,13 @@ function reconcileFinalizedRecord(store, record) {
251
273
  * @param {string} executionId - UUID of the record to finalize
252
274
  * @returns {string} POSIX shell fragment
253
275
  */
254
- function buildDetachedFinalizeSnippet(executionId) {
276
+ function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
255
277
  const v = SHELL_VARS;
256
278
  return (
257
279
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
258
280
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
259
281
  `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
260
- `"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1 || true`
282
+ `"$${CGROUP_SHELL_VARS.sample}"${attemptNumber === null ? '' : ` ${shellQuote(attemptNumber)}`} >/dev/null 2>&1 || true`
261
283
  );
262
284
  }
263
285
 
@@ -279,6 +301,7 @@ function main(argv) {
279
301
  containerError,
280
302
  running,
281
303
  cgroupMemory,
304
+ attemptNumber,
282
305
  ] = argv;
283
306
  if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
284
307
  return;
@@ -300,6 +323,7 @@ function main(argv) {
300
323
  containerError,
301
324
  running,
302
325
  cgroupMemory,
326
+ attemptNumber: attemptNumber || null,
303
327
  });
304
328
  } catch {
305
329
  // Deliberately silent: see the exit-0 contract above.
@@ -0,0 +1,61 @@
1
+ /** Stream Docker output without buffering lines; observe only timestamped output. */
2
+ const fs = require('fs');
3
+ const { StringDecoder } = require('string_decoder');
4
+ const { activityPath } = require('./execution-attempt');
5
+
6
+ class OutputObserver {
7
+ constructor(logPath, number, since) {
8
+ this.logPath = logPath;
9
+ this.activity = activityPath({ logPath, attempt: { number } });
10
+ this.since = Date.parse(since);
11
+ this.prefix = '';
12
+ this.atLineStart = true;
13
+ this.latest = null;
14
+ this.decoder = new StringDecoder('utf8');
15
+ }
16
+
17
+ write(chunk) {
18
+ fs.appendFileSync(this.logPath, chunk);
19
+ let latest = null;
20
+ for (const character of this.decoder.write(chunk)) {
21
+ if (character === '\n') {
22
+ this.atLineStart = true;
23
+ this.prefix = '';
24
+ } else if (this.atLineStart) {
25
+ this.prefix += character;
26
+ if (character === ' ') {
27
+ const text = this.prefix.trim();
28
+ const time = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(
29
+ text
30
+ )
31
+ ? Date.parse(text)
32
+ : NaN;
33
+ if (
34
+ Number.isFinite(time) &&
35
+ time >= this.since &&
36
+ (this.latest === null || time > this.latest)
37
+ ) {
38
+ this.latest = time;
39
+ latest = new Date(time).toISOString();
40
+ }
41
+ this.atLineStart = false;
42
+ } else if (this.prefix.length > 80) {
43
+ this.atLineStart = false;
44
+ }
45
+ }
46
+ }
47
+ if (latest) {
48
+ const temporary = `${this.activity}.${process.pid}.tmp`;
49
+ fs.writeFileSync(temporary, latest);
50
+ fs.renameSync(temporary, this.activity);
51
+ }
52
+ }
53
+ }
54
+
55
+ if (require.main === module) {
56
+ const [logPath, number, since] = process.argv.slice(2);
57
+ const observer = new OutputObserver(logPath, Number(number), since);
58
+ process.stdin.on('data', (chunk) => observer.write(chunk));
59
+ }
60
+
61
+ module.exports = { OutputObserver };
@@ -1,4 +1,5 @@
1
1
  const { spawn, spawnSync } = require('child_process');
2
+ const path = require('path');
2
3
  const {
3
4
  appendLogFile,
4
5
  createShellLogFooterSnippet,
@@ -381,7 +382,16 @@ function buildDetachedDockerCompletionScript(
381
382
  const since = watcherOptions.since
382
383
  ? ` --since ${shellQuote(watcherOptions.since)}`
383
384
  : '';
384
- parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
385
+ if (watcherOptions.attemptNumber) {
386
+ const capture = shellQuote(path.join(__dirname, 'detached-output.js'));
387
+ parts.push(
388
+ `docker logs -f --timestamps${since} ${quotedName} 2>&1 | ${shellQuote(process.execPath)} ${capture} ${quotedLogPath} ${watcherOptions.attemptNumber} ${shellQuote(watcherOptions.since)}`
389
+ );
390
+ } else {
391
+ parts.push(
392
+ `docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`
393
+ );
394
+ }
385
395
  parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
386
396
  parts.push(buildCgroupSamplerStopSnippet());
387
397
  parts.push(buildDockerStateSnippet(containerName));
@@ -420,7 +430,9 @@ function buildDetachedDockerCompletionScript(
420
430
 
421
431
  if (executionId) {
422
432
  // Last, so the record is only marked terminal once the log is complete.
423
- exited.push(buildDetachedFinalizeSnippet(executionId));
433
+ exited.push(
434
+ buildDetachedFinalizeSnippet(executionId, watcherOptions.attemptNumber)
435
+ );
424
436
  }
425
437
 
426
438
  const stillRunning = logPath
@@ -474,7 +486,13 @@ function startDetachedDockerCompletionWatcher(
474
486
  stdio: 'ignore',
475
487
  }
476
488
  );
489
+ watcher.on('error', (error) => {
490
+ if (process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true') {
491
+ console.error(`[DEBUG] completion watcher failed: ${error.message}`);
492
+ }
493
+ });
477
494
  watcher.unref();
495
+ return Boolean(watcher.pid);
478
496
  }
479
497
 
480
498
  function spawnAttachedDocker(dockerArgs, logPath) {
@@ -0,0 +1,153 @@
1
+ /** Attempt-scoped evidence for logical executions sharing a UUID and log. */
2
+ const fs = require('fs');
3
+ const { appendLogFile, readLogTail } = require('./isolation-log-utils');
4
+
5
+ const TERMINAL_FIELDS = [
6
+ 'endTimeSource',
7
+ 'observedAt',
8
+ 'staleDetectedAt',
9
+ 'containerStartedAt',
10
+ 'exitReason',
11
+ 'oomKilled',
12
+ 'memoryExhausted',
13
+ 'memoryExhaustedReason',
14
+ 'cgroupMemory',
15
+ ];
16
+
17
+ function activityPath(record) {
18
+ return `${record.logPath}.attempt-${record.attempt.number}.activity`;
19
+ }
20
+
21
+ function readAttemptActivity(record) {
22
+ if (!record.attempt || !record.logPath) {
23
+ return record;
24
+ }
25
+ let fd;
26
+ try {
27
+ fd = fs.openSync(activityPath(record), 'r');
28
+ const buffer = Buffer.alloc(128);
29
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
30
+ const at = buffer.subarray(0, length).toString().trim();
31
+ if (
32
+ Number.isFinite(Date.parse(at)) &&
33
+ Date.parse(at) >= Date.parse(record.attempt.startedAt)
34
+ ) {
35
+ record.attempt = { ...record.attempt, lastOutputAt: at };
36
+ }
37
+ } catch {
38
+ // Quiet, missing, or unreadable output is not evidence of task progress.
39
+ } finally {
40
+ if (fd !== undefined) {
41
+ fs.closeSync(fd);
42
+ }
43
+ }
44
+ return record;
45
+ }
46
+
47
+ function createAttempt(record, plan) {
48
+ let logOffset = null;
49
+ if (record.logPath) {
50
+ try {
51
+ logOffset = fs.statSync(record.logPath).size;
52
+ } catch (error) {
53
+ if (error.code === 'ENOENT') {
54
+ logOffset = 0;
55
+ }
56
+ }
57
+ }
58
+ return {
59
+ number:
60
+ (record.attempt?.number ||
61
+ (Number(record.options?.resumeCount) || 0) +
62
+ (Number(record.options?.recoveryAttempts) || 0) +
63
+ 1) + 1,
64
+ startedAt: new Date().toISOString(),
65
+ logOffset,
66
+ lastOutputAt: null,
67
+ launchAcceptedAt: null,
68
+ watcherAttachedAt: null,
69
+ mode: plan.mode,
70
+ previousSessionName: record.options?.sessionName || null,
71
+ sessionName: plan.newSessionName || plan.sessionName,
72
+ };
73
+ }
74
+
75
+ function archiveAttempt(record) {
76
+ readAttemptActivity(record);
77
+ const snapshot = {
78
+ ...(record.attempt || {
79
+ number:
80
+ (Number(record.options?.resumeCount) || 0) +
81
+ (Number(record.options?.recoveryAttempts) || 0) +
82
+ 1,
83
+ startedAt: record.options?.resumedAt || record.startTime,
84
+ logOffset: null,
85
+ }),
86
+ command: record.command,
87
+ sessionName: record.options?.sessionName || null,
88
+ containerId: record.options?.containerId || null,
89
+ containerError: record.options?.containerError || null,
90
+ status: record.status,
91
+ exitCode: record.exitCode,
92
+ endTime: record.endTime,
93
+ };
94
+ for (const field of TERMINAL_FIELDS) {
95
+ if (record[field] !== undefined) {
96
+ snapshot[field] = record[field];
97
+ }
98
+ record[field] = undefined;
99
+ }
100
+ record.attemptHistory = [...(record.attemptHistory || []), snapshot];
101
+ delete record.options.containerError;
102
+ }
103
+
104
+ function appendLifecycle(record, event, details = {}) {
105
+ if (!record.logPath || !record.attempt) {
106
+ return;
107
+ }
108
+ appendLogFile(
109
+ record.logPath,
110
+ `\n[Start Command Lifecycle] ${JSON.stringify({
111
+ event,
112
+ at: new Date().toISOString(),
113
+ uuid: record.uuid,
114
+ attemptNumber: record.attempt.number,
115
+ ...record.attempt,
116
+ ...details,
117
+ })}\n`
118
+ );
119
+ }
120
+
121
+ function patchAttempt(store, record, fields) {
122
+ if (typeof store.patchAttempt === 'function') {
123
+ return store.patchAttempt(record.uuid, record.attempt.number, fields);
124
+ }
125
+ // Compatibility for consumers injecting a minimal in-memory store.
126
+ const current = store.get(record.uuid) || record;
127
+ if (current.attempt?.number !== record.attempt.number) {
128
+ return null;
129
+ }
130
+ current.attempt = { ...current.attempt, ...fields };
131
+ store.save(current);
132
+ return current;
133
+ }
134
+
135
+ function readAttemptLogTail(record, bytes) {
136
+ if (
137
+ !record.logPath ||
138
+ (record.attempt && record.attempt.logOffset === null)
139
+ ) {
140
+ return null;
141
+ }
142
+ return readLogTail(record.logPath, bytes, record.attempt?.logOffset || 0);
143
+ }
144
+
145
+ module.exports = {
146
+ activityPath,
147
+ appendLifecycle,
148
+ archiveAttempt,
149
+ createAttempt,
150
+ patchAttempt,
151
+ readAttemptActivity,
152
+ readAttemptLogTail,
153
+ };
@@ -318,8 +318,34 @@ function recoverKilledExecution(params = {}) {
318
318
  }
319
319
 
320
320
  const since = now().toISOString();
321
+ const {
322
+ appendLifecycle,
323
+ archiveAttempt,
324
+ createAttempt,
325
+ patchAttempt,
326
+ } = require('./execution-attempt');
327
+ const nextAttempt = record.attempt
328
+ ? createAttempt(record, {
329
+ mode: 'automatic-recovery',
330
+ sessionName: containerName,
331
+ })
332
+ : null;
333
+ if (nextAttempt) {
334
+ nextAttempt.startedAt = since;
335
+ appendLifecycle(
336
+ { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
337
+ 'resume-started'
338
+ );
339
+ }
321
340
  const started = runner(getDockerCommand(), ['start', containerName]);
322
341
  if (!started.success) {
342
+ if (nextAttempt) {
343
+ appendLifecycle(
344
+ { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
345
+ 'launch-failed',
346
+ { error: failureDetail(started) }
347
+ );
348
+ }
323
349
  return giveUp(`docker start failed: ${failureDetail(started)}`);
324
350
  }
325
351
 
@@ -327,6 +353,19 @@ function recoverKilledExecution(params = {}) {
327
353
  const delayed = delayRange ? `, delayMs=${delayMs}` : '';
328
354
  // The killed run's own counters: the resumed run starts a fresh cgroup.
329
355
  const memory = parseCgroupMemorySample(params.cgroupMemory);
356
+ if (nextAttempt) {
357
+ Object.assign(record, {
358
+ status: 'executed',
359
+ exitCode: described.code,
360
+ endTime: params.finishedAt || null,
361
+ containerStartedAt: params.startedAt || undefined,
362
+ oomKilled: params.oomKilled === true || params.oomKilled === 'true',
363
+ cgroupMemory: memory || undefined,
364
+ });
365
+ archiveAttempt(record);
366
+ nextAttempt.launchAcceptedAt = new Date().toISOString();
367
+ record.attempt = nextAttempt;
368
+ }
330
369
  const counted = memory
331
370
  ? ['oomEvents', 'oomKills']
332
371
  .filter((field) => memory[field] !== null)
@@ -350,6 +389,12 @@ function recoverKilledExecution(params = {}) {
350
389
  record.exitReason = undefined;
351
390
  record.oomKilled = undefined;
352
391
  record.cgroupMemory = undefined;
392
+ record.memoryExhausted = undefined;
393
+ record.memoryExhaustedReason = undefined;
394
+ record.endTimeSource = undefined;
395
+ record.observedAt = undefined;
396
+ record.staleDetectedAt = undefined;
397
+ record.containerStartedAt = undefined;
353
398
  try {
354
399
  store.save(record);
355
400
  } catch {
@@ -359,13 +404,28 @@ function recoverKilledExecution(params = {}) {
359
404
 
360
405
  const startWatcher =
361
406
  params.startWatcher || startDetachedDockerCompletionWatcher;
407
+ if (nextAttempt) {
408
+ appendLifecycle(record, 'launch-accepted');
409
+ }
362
410
  startWatcher(
363
411
  containerName,
364
412
  getDockerContainerCleanupPolicy(opts),
365
413
  record.logPath || null,
366
414
  record.uuid,
367
- { since, recoverOnKill: true }
415
+ {
416
+ since,
417
+ recoverOnKill: true,
418
+ ...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
419
+ }
368
420
  );
421
+ if (nextAttempt) {
422
+ const current = patchAttempt(store, record, {
423
+ watcherAttachedAt: new Date().toISOString(),
424
+ });
425
+ if (current) {
426
+ appendLifecycle(current, 'watcher-attached');
427
+ }
428
+ }
369
429
  return {
370
430
  recovered: true,
371
431
  reason: 'resumed',
@@ -131,7 +131,18 @@ function resumeAllExecutions(store, deps = {}) {
131
131
  sessionName,
132
132
  getDockerContainerCleanupPolicy(record.options || {}),
133
133
  record.logPath || null,
134
- record.uuid || null
134
+ record.uuid || null,
135
+ record.attempt
136
+ ? {
137
+ since: record.attempt.startedAt,
138
+ attemptNumber: record.attempt.number,
139
+ recoverOnKill:
140
+ Boolean(record.options?.onKillResume) &&
141
+ ['docker-start', 'automatic-recovery', 'relaunch'].includes(
142
+ record.attempt.mode
143
+ ),
144
+ }
145
+ : {}
135
146
  );
136
147
  executions.push(
137
148
  describeRecord(